added todos | payday time settings | refactor payment scheduling

This commit is contained in:
JCEEE
2026-08-04 08:34:14 +01:00
parent ccbbd302d4
commit 85ea97ae96
16 changed files with 2877 additions and 659 deletions
+638 -79
View File
@@ -6,6 +6,16 @@
import { memberApi } from '$lib/client/api';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import {
weekStart as tzWeekStart,
addDaysStr,
todayInTz,
weekdayInTz,
wallClockToUtc,
resolveTz,
periodStart,
periodEnd
} from '../../../../../timezone.ts';
interface Notification {
id: string;
@@ -22,9 +32,16 @@
let famSlug = $derived(page.params.fam);
let username = $derived(page.params.username);
// ─── Family timezone (resolved) ───
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
const famTz = $derived(resolveTz(rawFamTz));
// ─── Parent View (admin overview) ───
let summary = $state(data.summary);
let today = $state(new Date().toISOString().slice(0, 10));
let today = $derived(todayInTz(famTz));
let simulateEow = $state(!!data.settings?.simulateEow);
let eowPreview = $state<any>(null);
const responseTags = [
'👏 well done',
@@ -70,7 +87,10 @@
}
function claimableRewards() {
return parentRewards.filter((r: any) => r.status === 'unclaimed' || r.status === 'requested');
return parentRewards.filter(
(r: any) =>
r.rewardType !== 'points' && (r.status === 'unclaimed' || r.status === 'requested')
);
}
function memberClaimable(memberId: string) {
@@ -78,17 +98,14 @@
}
function memberRequested(memberId: string) {
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'requested');
return parentRewards.filter(
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'requested'
);
}
function memberOutstanding(memberId: string) {
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'unclaimed');
}
function memberRequestedTotal(memberId: string) {
return memberRequested(memberId)
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
return parentRewards.filter(
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'unclaimed'
);
}
let _confetti: any;
@@ -174,20 +191,41 @@
let error = $state('');
let claimError = $state('');
let todayChild = $state(new Date().toISOString().slice(0, 10));
let todayChild = $derived(todayInTz(famTz));
let togglingIds = $state<string>('');
function mondayOf(d: Date): string {
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return d.toISOString().slice(0, 10);
// ─── Payday countdown (child view) ───
let nowMs = $state(Date.now());
let eowFired = $state(false);
$effect(() => {
const id = setInterval(() => (nowMs = Date.now()), 1000);
return () => clearInterval(id);
});
const paydayDay = $derived(data.payday != null ? Number(data.payday) : 1);
const paydayTime = $derived(data.paydayTime || '18:00');
const isPaydayToday = $derived(weekdayInTz(new Date(), famTz) === paydayDay);
const paydayTarget = $derived.by(() =>
new Date(wallClockToUtc(todayInTz(famTz), paydayTime, famTz)).getTime()
);
const secondsLeft = $derived(Math.max(0, Math.floor((paydayTarget - nowMs) / 1000)));
const countdownHours = $derived(Math.floor(secondsLeft / 3600));
const countdownMinutes = $derived(Math.floor((secondsLeft % 3600) / 60));
const countdownSecs = $derived(secondsLeft % 60);
const showCountdown = $derived(isPaydayToday && secondsLeft > 0);
$effect(() => {
if (role !== 'child' || !isPaydayToday) return;
if (secondsLeft > 0 || eowFired) return;
if (!deviceToken || !famId) return;
eowFired = true;
memberApi.payday(deviceToken, famId).catch(() => {});
});
function paydayWeekStart(): string {
return tzWeekStart(paydayDay, famTz);
}
function addDays(dateStr: string, days: number): string {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
return addDaysStr(dateStr, days);
}
function monthStartStr(): string {
@@ -195,8 +233,8 @@
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
}
const todayIso = new Date().toISOString().slice(0, 10);
const currentWeek = mondayOf(new Date());
const todayIso = todayInTz(famTz);
const currentWeek = paydayWeekStart();
let weekStart = $state(currentWeek);
let weekEnd = $derived(addDays(weekStart, 6));
@@ -205,7 +243,7 @@
Math.max(
0,
Math.round(
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
86400000
)
)
@@ -216,18 +254,46 @@
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
return assigned.filter((a) => {
if (a.memberId !== memberId) return false;
if (a.isTodo) return false;
if (!a.seasonIds || a.seasonIds.length === 0) return true;
if (activeSeasonIds.size === 0) return true;
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
});
});
// Todos — one-off items with deadlines (sorted: soonest first)
let memberTodos = $derived.by(() =>
assigned
.filter((a) => a.memberId === memberId && a.isTodo)
.sort((a, b) => {
const da = a.completeBy || '9999-99-99';
const db = b.completeBy || '9999-99-99';
if (da !== db) return da.localeCompare(db);
return (a.startDate || '').localeCompare(b.startDate || '');
})
);
let dailyPending = $derived(
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
);
let weeklyPending = $derived(
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
);
// Traffic light: days until todo deadline (only non-emoji todos)
function todoUrgency(todo: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
if (!todo.completeBy) return 'blue';
const days = Math.round(
(new Date(todo.completeBy + 'T00:00:00').getTime() -
new Date(todayChild + 'T00:00:00').getTime()) /
86400000
);
if (days <= 0) return 'black';
if (days === 1) return 'red';
if (days === 2) return 'orange';
if (days === 3) return 'green';
return 'blue';
}
let completedToday = $derived(
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
);
@@ -235,7 +301,8 @@
let weeklyTotal = $derived.by(() => {
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
return dailyChores.length * 7 + weeklyChores.length;
const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length;
return dailyChores.length * 7 + weeklyChores.length + activeTodos;
});
let weekCompletions = $derived(
@@ -253,46 +320,56 @@
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
);
let allTimeCash = $derived.by(() => {
const myCompletions = completions.filter((c) => c.memberId === memberId);
const myRewards = rewards.filter((r) => r.memberId === memberId);
return (
myCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0) +
myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
);
});
let allTimeCash = $derived.by(() =>
rewards
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0)
);
let allTimePoints = $derived.by(() => {
const myCompletions = completions.filter((c) => c.memberId === memberId);
const myRewards = rewards.filter((r) => r.memberId === memberId);
return (
myCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0) +
myRewards
.filter((r) => r.rewardType === 'points')
.reduce((sum, r) => sum + Number(r.value), 0)
);
});
let allTimePoints = $derived.by(() =>
rewards
.filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0)
);
// Week-to-date totals from chore completions (not rewards — rewards are created at EOW)
let weekChoreCash = $derived.by(() =>
weekCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0)
);
let weekChorePoints = $derived.by(() =>
weekCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0)
);
let pendingCash = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0)
);
let pendingPoints = $derived.by(() =>
// Money owed to this member across all time (incl. carry-over from past weeks).
let owedCash = $derived.by(() =>
rewards
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status !== 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0)
);
let weekPointsEarned = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'points' && r.status !== 'claimed')
.filter((r) => r.rewardType === 'points')
.reduce((sum, r) => sum + Number(r.value), 0)
);
const pendingRewards = $derived(
weekRewards.filter((r) => r.status === 'unclaimed' || r.status === 'requested')
rewards.filter(
(r) =>
r.memberId === memberId &&
r.rewardType !== 'points' &&
(r.status === 'unclaimed' || r.status === 'requested')
)
);
let weekBonusTallies = $derived.by(() => {
const map = new Map<string, number>();
for (const r of weekRewards) {
@@ -303,6 +380,55 @@
return [...map.entries()].map(([name, count]) => ({ name, count }));
});
// ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => {
return bonusConfigs
.filter((cfg) => {
if (cfg.status !== 'active') return false;
if (cfg.type === 'manual') return false;
if (cfg.target !== 'individual') return false;
return !cfg.memberId || cfg.memberId === memberId;
})
.map((cfg) => {
const per = cfg.period || 'weekly';
const pStart = periodStart(per, paydayDay, famTz);
const pEnd = periodEnd(per, pStart);
const periodCompletions = completions.filter(
(c) =>
c.memberId === memberId &&
(c.date?.slice(0, 10) || c.date) >= pStart &&
(c.date?.slice(0, 10) || c.date) <= pEnd
);
let current = 0;
if (cfg.type === 'threshold') {
current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
current = periodCompletions.length;
}
const existingReward = rewards.find(
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
);
const criteria = Number(cfg.criteriaValue) || 0;
const achieved = existingReward ? true : criteria > 0 && current >= criteria;
return {
config: cfg,
current,
criteriaValue: criteria,
achieved,
rewardStatus: existingReward?.status || null,
periodStart: pStart,
periodEnd: pEnd
};
});
});
function shortDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
@@ -598,12 +724,38 @@
{#each parentMembers as m}
{@const requested = memberRequested(m.id)}
{@const outstanding = memberOutstanding(m.id)}
{@const total = memberRequestedTotal(m.id)}
{#if requested.length > 0 || outstanding.length > 0}
{@const claimable = memberClaimable(m.id)}
{@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')}
{@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')}
{@const requestedTotal = cashRequested.reduce(
(sum: number, r: any) => sum + Number(r.value),
0
)}
{@const total = cashClaimable.reduce((sum: number, r: any) => sum + Number(r.value), 0)}
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
{#if hasClaims}
<div class="member-claims">
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
{#if total > 0}
<p class="member-total">£{total.toFixed(2)} requested</p>
<p class="member-total">£{total.toFixed(2)} owed</p>
{/if}
<!-- Issue All: only when payday has run (rewards are requested) -->
{#if cashRequested.length > 0}
<form
class="issue-all-form"
method="POST"
action="?/issueAll"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="memberId" value={m.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<Button type="submit" size="sm" variant="secondary"
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
>
</form>
{/if}
{#if requested.length > 0}
@@ -627,21 +779,6 @@
</div>
</form>
{/each}
{#if requested.length > 1}
<form
method="POST"
action="?/issueAll"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="memberId" value={m.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<Button type="submit" size="sm" variant="secondary"
>Issue All ({requested.length})</Button
>
</form>
{/if}
{/if}
{#if outstanding.length > 0}
@@ -684,6 +821,82 @@
{/if}
</Card>
</CardGrid>
<CardGrid>
<Card cols={3} title="Debug: Simulate Payday" accent="#f59e0b">
<p class="eow-desc">
Read-only preview of what the payday rollover will produce. Nothing here is written.
</p>
<form
method="POST"
action="?/setEow"
use:enhance={() => {
return async ({ result }) => {
const d = (result as any).data || {};
if (d.error) toast = d.error;
else simulateEow = !!d.simulateEow;
};
}}
>
<input type="hidden" name="on" value={simulateEow ? 'false' : 'true'} />
<button type="submit" class="eow-switch" class:on={simulateEow}>
{simulateEow ? 'Simulation ON' : 'Simulation OFF'}
</button>
</form>
<p class="eow-note">
This toggles the family debug flag only. It does not alter any live data.
</p>
<form
method="POST"
action="?/previewEow"
use:enhance={() => {
return async ({ result }) => {
const d = (result as any).data || {};
if (d.error) toast = d.error;
else eowPreview = d.preview;
};
}}
>
<Button type="submit" size="sm" variant="secondary">Preview rollover</Button>
</form>
{#if eowPreview}
<div class="eow-out">
<p class="eow-range">Week {eowPreview.weekStart}{eowPreview.weekEnd}</p>
<p class="eow-reset">
On rollover these {eowPreview.completionsThisWeek} completions reset; week starts anew at
{eowPreview.nextWeekStart}.
</p>
<div class="eow-summaries">
{#each eowPreview.summaries as s}
<div class="eow-row">
<span class="eow-name">{s.memberName}</span>
<span class="eow-pts">{s.pointsEarned} pts</span>
<span class="eow-money">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
<span class="eow-chores">{s.choresCompleted} chores</span>
</div>
{/each}
</div>
{#if eowPreview.predictedRewards.length}
<p class="eow-sec">Rewards that would be auto-created</p>
<div class="eow-rewards">
{#each eowPreview.predictedRewards as r}
<span class="eow-reward"
>{r.memberName} · {r.config} · {r.type === 'cash'
? '£' + Number(r.value).toFixed(2)
: r.value + ' pts'}</span
>
{/each}
</div>
{:else}
<p class="eow-none">No new rewards would be auto-created this week.</p>
{/if}
</div>
{/if}
</Card>
</CardGrid>
{:else}
<!-- Child View -->
{#if loading}
@@ -691,6 +904,27 @@
{:else if error}
<p class="error">{error}</p>
{:else}
{#if owedCash > 0}
<div class="payday-banner">
🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent!
</div>
{/if}
{#if showCountdown}
<div class="payday-countdown">
<div class="pd-icon">💰</div>
<div class="pd-body">
<p class="pd-title">Payday is today!</p>
<p class="pd-num">
{#if countdownHours > 0}
{countdownHours}h {countdownMinutes}m left
{:else}
{countdownMinutes}m {countdownSecs}s left
{/if}
</p>
<p class="pd-sub">Your earnings get settled at {paydayTime}</p>
</div>
</div>
{/if}
<!-- HERO -->
<header class="hero">
<div class="hero-top">
@@ -822,7 +1056,7 @@
{:else}
<span class="hero-null">no bonuses earned yet</span>
{/if}
<div class="hero-days"><b>{daysLeft}</b> days left</div>
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
</div>
<nav class="hero-nav">
@@ -836,18 +1070,55 @@
</nav>
</header>
<!-- PENDING TILES -->
<!-- WEEK-TO-DATE TILES -->
<div class="tiles">
<div class="tile tile-cash">
<span class="tile-val">£{pendingCash.toFixed(2)}</span>
<span class="tile-lbl">pending cash</span>
<span class="tile-val">£{weekChoreCash.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span>
</div>
<div class="tile tile-pts">
<span class="tile-val">{pendingPoints}</span>
<span class="tile-lbl">pending points</span>
<span class="tile-val">{weekChorePoints}</span>
<span class="tile-lbl">points this week</span>
</div>
</div>
<!-- GOALS (threshold bonus configs) -->
{#if thresholdGoals.length > 0}
<div class="goals">
{#each thresholdGoals as goal}
{@const pct =
goal.criteriaValue > 0
? Math.min(100, Math.round((goal.current / goal.criteriaValue) * 100))
: 0}
<div class="goal-card" class:goal-achieved={goal.achieved}>
<div class="goal-head">
<span class="goal-name">{goal.config.name}</span>
<span class="goal-reward">
{#if goal.config.rewardType === 'cash'}
£{Number(goal.config.rewardValue).toFixed(2)}
{:else if goal.config.rewardType === 'prize'}
🎁
{:else}
{goal.config.rewardValue} pts
{/if}
</span>
</div>
<div class="goal-bar-wrap">
<div class="goal-bar-fill" style="width:{pct}%"></div>
</div>
<div class="goal-foot">
<span class="goal-progress">{goal.current} / {goal.criteriaValue}</span>
{#if goal.achieved}
<span class="goal-badge">✅ earned</span>
{:else}
<span class="goal-pct">{pct}%</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- KANBAN -->
<div class="kanban">
<div class="column col-daily">
@@ -865,8 +1136,11 @@
{/if}
</div>
<div class="column col-weekly">
<h2>📅 Weekly ({weeklyPending.length})</h2>
{#if weeklyPending.length === 0}
<h2>
📅 Weekly ({weeklyPending.length +
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
</h2>
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
<p class="empty">All done!</p>
{:else}
{#each weeklyPending as chore}
@@ -876,6 +1150,38 @@
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
{@const urgency = todoUrgency(todo)}
{#if todo.type === 'emoji'}
<div
class="chore todo-chore todo-display"
class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'}
>
<span class="checkbox">📋</span>
<span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="chore-value">🎯</span>
</div>
{:else}
<button
class="chore todo-chore"
class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'}
onclick={() => toggle(todo)}
disabled={!!togglingIds}
>
<span class="checkbox">📋</span>
<span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="chore-value">{todo.value} {todo.type}</span>
</button>
{/if}
{/each}
{/if}
</div>
<div class="column col-done">
@@ -926,7 +1232,9 @@
{r.value} pts
{/if}
</span>
{#if isReq}
{#if r.rewardType === 'points'}
<span class="wr-pending">✓ credited</span>
{:else if isReq}
<span class="wr-pending">⏳ waiting</span>
{:else}
<button
@@ -1439,6 +1747,82 @@
opacity: 0.9;
}
/* ── Goals (threshold bonus progress) ── */
.goals {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.goal-card {
background: #fff;
border: 1px solid #e5e7eb;
border-left: 4px solid #6366f1;
border-radius: 10px;
padding: 0.75rem 1rem;
transition: border-color 0.2s;
}
.goal-achieved {
border-left-color: #10b981;
background: #f0fdf4;
}
.goal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.goal-name {
font-size: 0.9rem;
font-weight: 700;
color: #1f2937;
}
.goal-reward {
font-size: 0.85rem;
font-weight: 800;
color: #6366f1;
white-space: nowrap;
}
.goal-achieved .goal-reward {
color: #059669;
}
.goal-bar-wrap {
height: 8px;
background: #f3f4f6;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.35rem;
}
.goal-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
}
.goal-achieved .goal-bar-fill {
background: linear-gradient(90deg, #10b981, #34d399);
}
.goal-foot {
display: flex;
justify-content: space-between;
align-items: center;
}
.goal-progress {
font-size: 0.75rem;
color: #6b7280;
font-weight: 600;
}
.goal-badge {
font-size: 0.75rem;
font-weight: 700;
color: #059669;
}
.goal-pct {
font-size: 0.75rem;
font-weight: 600;
color: #6366f1;
}
/* ── Kanban ── */
.kanban {
display: grid;
@@ -1489,6 +1873,37 @@
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
border-color: #c7d2fe;
}
/* ── Todo traffic light borders ── */
.chore.todo-chore {
border-left: 4px solid #3b82f6;
}
.chore.todo-blue {
border-left-color: #3b82f6;
}
.chore.todo-green {
border-left-color: #22c55e;
}
.chore.todo-orange {
border-left-color: #f97316;
}
.chore.todo-red {
border-left-color: #ef4444;
}
.chore.todo-black {
border-left-color: #1f2937;
}
.todo-display {
cursor: default;
opacity: 0.85;
}
.todo-display:hover {
transform: none;
box-shadow: none;
border-top-color: #e5e7eb;
border-right-color: #e5e7eb;
border-bottom-color: #e5e7eb;
}
.chore:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -1602,4 +2017,148 @@
.wr-cta:hover {
filter: brightness(1.1);
}
.eow-desc {
margin: 0 0 0.6rem;
}
.payday-banner {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 1rem;
padding: 0.85rem 1.1rem;
border-radius: 12px;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
color: #451a03;
font-weight: 600;
font-size: 1.05rem;
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
}
.payday-banner b {
font-weight: 800;
}
.payday-countdown {
display: flex;
align-items: center;
gap: 0.9rem;
margin-bottom: 1rem;
padding: 1rem 1.2rem;
border-radius: 12px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
color: #fff;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
}
.pd-icon {
font-size: 1.8rem;
}
.pd-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pd-title {
margin: 0;
font-weight: 700;
font-size: 0.95rem;
opacity: 0.9;
}
.pd-num {
margin: 0;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
}
.pd-sub {
margin: 0;
font-size: 0.8rem;
opacity: 0.85;
}
.eow-switch {
font-size: 0.85rem;
font-weight: 700;
padding: 0.45rem 1rem;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
cursor: pointer;
}
.eow-switch.on {
background: #f59e0b;
color: #fff;
}
.eow-note {
font-size: 0.75rem;
color: #9ca3af;
margin: 0.4rem 0 0.6rem;
}
.eow-out {
margin-top: 0.75rem;
border-top: 1px dashed #e5e7eb;
padding-top: 0.6rem;
}
.eow-range,
.eow-reset {
margin: 0.2rem 0;
font-size: 0.85rem;
}
.eow-reset {
color: #6b7280;
font-size: 0.8rem;
}
.eow-summaries {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.6rem 0;
}
.eow-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.5rem;
border: 1px solid #eeeff2;
border-radius: 8px;
background: #fafbfc;
font-size: 0.85rem;
}
.eow-name {
flex: 1;
font-weight: 600;
}
.eow-pts {
color: #7c3aed;
font-weight: 700;
}
.eow-money {
color: #059669;
font-weight: 700;
}
.eow-chores {
color: #6b7280;
}
.eow-sec {
font-size: 0.85rem;
font-weight: 700;
margin: 0.6rem 0 0.3rem;
}
.eow-rewards {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.eow-reward {
font-size: 0.8rem;
padding: 0.3rem 0.55rem;
background: #eef2ff;
color: #4338ca;
border-radius: 6px;
}
.eow-none {
font-size: 0.8rem;
color: #9ca3af;
}
</style>