diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte index c396fc1..3899029 100644 --- a/frontend/src/lib/components/Card.svelte +++ b/frontend/src/lib/components/Card.svelte @@ -1,8 +1,17 @@ -
+
{#if title}
{title} @@ -20,12 +29,20 @@ border-radius: 10px; overflow: hidden; } - .card.has-accent { border-top: 3px solid var(--card-accent, #6366f1); } + .card.has-accent { + border-top: 3px solid var(--card-accent, #6366f1); + } .card-header { padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; background: #fafafa; } - .card-title { font-weight: 600; font-size: 0.9rem; color: #374151; } - .card-body { padding: 1rem; } + .card-title { + font-weight: 600; + font-size: 0.9rem; + color: #374151; + } + .card-body { + padding: 1rem; + } diff --git a/frontend/src/lib/components/CardGrid.svelte b/frontend/src/lib/components/CardGrid.svelte index 429de91..7160619 100644 --- a/frontend/src/lib/components/CardGrid.svelte +++ b/frontend/src/lib/components/CardGrid.svelte @@ -1,21 +1,14 @@ -
+
{@render children?.()}
diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 4602408..dfc0e18 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -173,24 +173,41 @@ let loading = $state(true); let error = $state(''); - let requestedRewards = $state([]); let claimError = $state(''); let todayChild = $state(new Date().toISOString().slice(0, 10)); let togglingIds = $state(''); - function weekStartStr(): string { - const d = new Date(); + 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); } + 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); + } + function monthStartStr(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; } + const todayIso = new Date().toISOString().slice(0, 10); + const currentWeek = mondayOf(new Date()); + let weekStart = $state(currentWeek); + let weekEnd = $derived(addDays(weekStart, 6)); + + const isCurrentWeek = $derived(weekStart === currentWeek); + const daysLeft = $derived( + Math.round( + (new Date(weekEnd + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) / + 86400000 + ) + ); + let memberChores = $derived.by(() => { if (role === 'parent' || !memberId) return []; const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id)); @@ -211,12 +228,6 @@ let completedToday = $derived( completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild) ); - let claimableRewardsChild = $derived( - rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed') - ); - let requestedRewardsChild = $derived( - rewards.filter((r) => r.memberId === memberId && r.status === 'requested') - ); let weeklyTotal = $derived.by(() => { const dailyChores = memberChores.filter((a) => a.frequency === 'daily'); @@ -224,14 +235,22 @@ return dailyChores.length * 7 + weeklyChores.length; }); - let weeklyDone = $derived.by(() => { - const ws = weekStartStr(); - return completions.filter( - (c) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws - ).length; - }); + let weekCompletions = $derived( + completions.filter( + (c) => + c.memberId === memberId && + (c.date?.slice(0, 10) || c.date) >= weekStart && + (c.date?.slice(0, 10) || c.date) <= weekEnd + ) + ); + let weeklyDone = $derived(weekCompletions.length); + let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0); - let totalCash = $derived.by(() => { + let weekRewards = $derived( + 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 ( @@ -243,14 +262,7 @@ ); }); - let pendingCash = $derived.by(() => { - const myRewards = rewards.filter((r) => r.memberId === memberId); - return myRewards - .filter((r) => r.rewardType === 'cash' && r.status !== 'claimed') - .reduce((sum, r) => sum + Number(r.value), 0); - }); - - let totalPoints = $derived.by(() => { + let allTimePoints = $derived.by(() => { const myCompletions = completions.filter((c) => c.memberId === memberId); const myRewards = rewards.filter((r) => r.memberId === memberId); return ( @@ -264,64 +276,66 @@ ); }); - let bonusNotices = $derived.by(() => { - const myRewards = rewards.filter((r) => r.memberId === memberId); - return myRewards.filter((r) => r.status === 'claimed' && r.claimedAt?.slice(0, 10) === r.date); + 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(() => + weekRewards + .filter((r) => r.rewardType === 'points' && r.status !== 'claimed') + .reduce((sum, r) => sum + Number(r.value), 0) + ); + const pendingRewards = $derived( + weekRewards.filter((r) => r.status === 'unclaimed' || r.status === 'requested') + ); + + let weekBonusTallies = $derived.by(() => { + const map = new Map(); + for (const r of weekRewards) { + if (r.memberId !== memberId) continue; + const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label; + map.set(name, (map.get(name) || 0) + 1); + } + return [...map.entries()].map(([name, count]) => ({ name, count })); }); - let bonusProgresses = $derived.by(() => { - const ws = weekStartStr(); - const myCompletions = completions.filter((c) => c.memberId === memberId); - const activeConfigs = bonusConfigs.filter( - (b: BonusConfig) => - b.status === 'active' && - b.target === 'individual' && - (!b.memberId || b.memberId === memberId) - ); - return activeConfigs.map((cfg) => { - const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : ''; - const pEnd = pStart - ? (() => { - if (cfg.period === 'weekly') { - const d = new Date(pStart); - d.setDate(d.getDate() + 6); - return d.toISOString().slice(0, 10); - } - if (cfg.period === 'monthly') { - const [y, m] = pStart.split('-').map(Number); - const lastDay = new Date(y, m, 0).getDate(); - return `${pStart.slice(0, 7)}-${String(lastDay).padStart(2, '0')}`; - } - return ''; - })() - : ''; - - const periodCompletions = pStart - ? myCompletions.filter( - (c) => - (c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd - ) - : myCompletions; - - 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; - } - - return { - config: cfg, - current, - criteria: cfg.criteriaValue || 0, - 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'); + const mon = String(d.getMonth() + 1).padStart(2, '0'); + return `${day}/${mon}/${String(d.getFullYear()).slice(2)}`; + } + const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + function headDate(): Date { + return new Date((isCurrentWeek ? todayIso : weekEnd) + 'T00:00:00'); + } + function headDay(): number { + return headDate().getDate(); + } + function headMonth(): string { + const d = headDate(); + return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; + } + function navigate(amount: number) { + weekStart = addDays(weekStart, amount * 7); + } + function jumpToNow() { + weekStart = currentWeek; + } onMount(async () => { if (role === 'parent') return; @@ -451,12 +465,6 @@ function choreName(chore: AssignedChore): string { return chore.customName || templateFor(chore)?.name || 'Chore'; } - - function rewardLabelChild(r: Reward): string { - if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`; - if (r.rewardType === 'points') return `${r.value} pts`; - return r.label; - } {#if role === 'parent'} @@ -675,33 +683,87 @@ {:else} - - - - -

+
+
+ { + e.stopPropagation(); + showColorPicker = !showColorPicker; + }} + onkeydown={(e) => e.key === 'Enter' && (showColorPicker = !showColorPicker)} + > + {#if editingName} + { + if (e.key === 'Enter') { + editingName = false; + if (nameInput && nameInput !== memberName) { + const old = memberName; + memberName = nameInput; + try { + const res = await fetch('/api/members/me', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'x-device-token': deviceToken, + 'x-device-famid': famId + }, + body: JSON.stringify({ name: nameInput }) + }); + if (!res.ok) memberName = old; + } catch { + memberName = old; + } + } + } else if (e.key === 'Escape') { + editingName = false; + nameInput = memberName; + } + }} + onblur={() => { + editingName = false; + nameInput = memberName; + }} + autofocus + /> + {:else} { - e.stopPropagation(); - showColorPicker = !showColorPicker; + onclick={() => { + nameInput = memberName; + editingName = true; }} - onkeydown={(e) => e.key === 'Enter' && (showColorPicker = !showColorPicker)} - > - {#if editingName} - { - if (e.key === 'Enter') { - editingName = false; - if (nameInput && nameInput !== memberName) { - const old = memberName; - memberName = nameInput; + onkeydown={(e) => e.key === 'Enter' && ((nameInput = memberName), (editingName = true))} + > + {memberName || username} + + {/if} +
+ + {#if showColorPicker} + +
(showColorPicker = false)}> +
e.stopPropagation()}> +

Pick a color:

+
+ {#each ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#78716c'] as color} +

- - {#if showColorPicker} - -
(showColorPicker = false)}> -
e.stopPropagation()}> -

Pick a color:

-
- {#each ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#78716c'] as color} - - {/each} -
+ }} + > + {/each}
- {/if} +
+ {/if} - {#if loading} -

Loading...

- {:else if error} -

{error}

- {:else} - -
-
- Bonus Notices - {bonusNotices.length} - {#if bonusNotices.length > 0} - +{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts - {/if} -
-
- Total Cash - £{totalCash.toFixed(2)} -
-
- Pending Cash - £{pendingCash.toFixed(2)} -
-
- Total Points - {totalPoints} -
-
+
+
+ {headDay()} + {headMonth()} +
+
+ {daysLeft} + days left +
+
- +
+ + + +
+
+ + {#if loading} +

Loading...

+ {:else if error} +

{error}

+ {:else} + + + {#if weeklyTotal > 0} - {@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)} -
-
- This Week - {weeklyDone}/{weeklyTotal} done -
-
-
-
+ {@const wpct = Math.max(0, Math.min(100, weeklyPct))} +
{weeklyDone}/{weeklyTotal} done
+
+
+
+ {:else} +

No chores assigned

{/if} + - - {#if bonusProgresses.length > 0} -
- {#each bonusProgresses as bp} - {@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0} - {@const hasReward = bp.periodStart - ? rewards.some( - (r) => - r.memberId === memberId && - r.bonusConfigId === bp.config.id && - r.date >= bp.periodStart && - r.date <= bp.periodEnd - ) - : rewards.some((r) => r.memberId === memberId && r.bonusConfigId === bp.config.id)} - {@const typeClass = - bp.config.type === 'threshold' - ? 'type-threshold' - : bp.config.type === 'count' - ? 'type-count' - : 'type-manual'} -
-
- {bp.config.name} - {bp.config.type} -
-
- {#if bp.criteria > 0} -
-
-
- {bp.current}/{bp.criteria} - {bp.config.type === 'threshold' ? 'pts' : 'chores'} - {/if} -
- {#if hasReward} - Reward earned 🎉 - {/if} + + {#if weekBonusTallies.length === 0} +

No bonuses yet

+ {:else} +
+ {#each weekBonusTallies as t} +
+ {t.name} + {t.count}
{/each}
{/if} +
- + +
+
+ Cash + £{allTimeCash.toFixed(2)} +
+
+ Points + {allTimePoints} +
+
+
+ + +
£{pendingCash.toFixed(2)}
+

pending this week

+
+ + +
{pendingPoints}
+

pending this week

+
+ + + {#if pendingRewards.length === 0} +

Nothing to claim

+ {:else} + {#each pendingRewards as r} + {@const isRequested = r.status === 'requested'} +
+ + {#if isRequested}🕓 requested{:else}▪️ claimable{/if} + + {r.label} + + {#if r.rewardType === 'cash'} + £{Number(r.value).toFixed(2)} + {:else if r.rewardType === 'prize'} + 🎁 {r.value} + {:else} + {r.value} pts + {/if} + + {#if isRequested} + waiting + {:else} + + {/if} +
+ {/each} + {/if} +
+ + + + +

Daily Pending ({dailyPending.length})

@@ -918,75 +959,10 @@ {/each} {/if}
- -
-

Claimable ({claimableRewardsChild.length})

- {#if claimableRewardsChild.length === 0} -

Nothing to claim

- {:else} - {#if claimableRewardsChild.length > 1} - - {/if} - {#each claimableRewardsChild as r} -
-
- {r.label} - {rewardLabelChild(r)} -
-
- {r.rewardType} - -
-
- {/each} - {/if} -
- -
-

Requested ({requestedRewardsChild.length})

- {#if requestedRewardsChild.length === 0} -

No pending requests

- {:else} - {#each requestedRewardsChild as r} -
-
- {r.label} - {rewardLabelChild(r)} -
-
- {r.rewardType} - Requested -
-
- {/each} - {/if} -
- {/if} -
-
+ + + {/if} {/if}