Compare commits

...

12 Commits

Author SHA1 Message Date
JCEEE f491dbfbf1 1.8.6 2026-08-31 19:27:00 +01:00
JCEEE fdacfc7e5c user testing and fixing minor issues 2026-08-31 19:26:55 +01:00
JCEEE a521edaabf 1.8.5 2026-08-31 18:03:47 +01:00
JCEEE 11a09d65ff enhance with charts and home layout, and make more mobile friendly 2026-08-31 18:03:44 +01:00
JCEEE 3bf78d777c updated rewards assignment and reward progress 2026-08-31 16:44:37 +01:00
JCEEE cda2cbfcab 1.8.4 2026-08-31 15:34:38 +01:00
JCEEE 852f9edb96 update some minor auth ux issues 2026-08-31 15:34:34 +01:00
JCEEE 035dbb829a update some minor ux issues 2026-08-31 15:33:17 +01:00
JCEEE 5165c59494 1.8.3 2026-08-28 19:33:11 +01:00
JCEEE d303eda038 remove payday debug and add multiple ui fixes 2026-08-28 19:32:32 +01:00
JCEEE bcdd3805d5 1.8.2 2026-08-27 14:13:48 +01:00
JCEEE 9f335db7b2 add more ux and ui patches with extra login helper 2026-08-27 14:13:14 +01:00
41 changed files with 2699 additions and 1208 deletions
+23
View File
@@ -321,6 +321,29 @@
- **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → Invites → **Account** (Access + Subscription) → App last. - **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → Invites → **Account** (Access + Subscription) → App last.
- **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes. - **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes.
### 2026-08-31 — All-time earnings + weekly trend chart (family root), lifetime wallet (member)
- **New server stats service** `frontend/src/lib/server/services/stats.ts` (`familyStats(pb, famId)`, exposed as `s.stats.family(famId)`): scans full-history `completions` + `rewards` once, filtered/summed **on the server**, returns fixed-size `{ allTimePts, allTimeCash, allTimeChores, series }` (per-member weekly buckets `{weekStart, points, cash, chores}`). Server-side aggregation keeps the client payload constant-size regardless of history depth — this is the chosen scaling win for all-time totals + the retrospective graph.
- **Totals definition (matches the app's own accounting, no double count):** lifetime points = points-type completions' chore values + **claimed** points rewards; lifetime cash = money-type completions' chore values + **claimed** cash rewards; chores = count of all completions. Unclaimed/requested rewards stay out of totals (they're the "to chase" list).
- **Reading `weekly_history` NOT needed** — totals derive from live `completions`+`rewards` (both have `listRule: ""` in SCHEMA_PLAN = open to any authenticated session, which is why parent/child token reads already work today).
- **Member wallet** (`[fam]/[username]/+page.svelte`): the existing `allTimeCash`/`allTimePoints` deriveds now include chore completion earnings (previously claimed-rewards only). Wallet UI restyled: big lifetime cells (cash "earned all time" + points "accrued") on top, then a "To collect from parent" divider + the existing pendingRewards list (unclaimed/requested/payday-locked) so members still see what to chase.
- **Family root** (`[fam]/+page.svelte` + `+page.server.ts`): load now fetches `familyStats` (+ drops the removed templates). ViewHeader gained an optional default slot (`children?: Snippet`); the fam hero renders large all-time points | cash inside the header. Chores card shows "this week | total all-time" with `justify-content: space-between`. Templates card removed. Chart.js line graph "Over time" plots per-member lines with a Points/Cash/Chores tab; window starts at the current month and expands a month at a time up to 3 months based on data span (≤31d→1, ≤62d→2, else 3).
- **Typecheck/build:** still 6 pre-existing canary errors only (none in edited files); `pnpm build` (adapter-node) clean. New code is picked up by HMR; no schema/migration change so no restart required.
### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented
- **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled *all* completions, and the child dashboard forced `cfg.period || 'weekly'`.
- **Fix**: added to `bonus_configs` (schema + `ensureBonusFields` idempotent field-add in migrate.ts, so existing installs upgrade without wiping): `completeBy` (select `unlimited|week|custom`), `startDate` (text), `completeByDate` (text). New shared helpers in `shared/timezone.ts`: `bonusWindow(cfg, payday, tz)` + `completionInWindow(c, {from,to})` ('' = unbounded side).
- **Window semantics** (unified across server `progress()`/`evaluateFam()` and the child `thresholdGoals`): recurring configs (period set) keep their period window; standalone configs use `completeBy`:
- `unlimited` (default) → `[startDate, ∞]` cumulative
- `week` → current week `[weekStart, weekEnd]`
- `custom` → `[startDate, completeByDate]`
- No `startDate`/`completeBy` (legacy) → unbounded both sides (== the pre-fix admin behaviour, so existing cash bonuses stay correct).
- **UI**: rewards modal gained a "Progress counts" group (shown when no period / not manual): `completeBy` radios (Unlimited | Until a date) + optional `completeByDate` date input + "Progress starts on" `startDate` date input. Creating from a template's `startMode` (Today/Next week) now sets `startDate` (Today = fam-tz today; Next week = +7 days). `openEditConfig`/`openCreateFromTemplate` default `startDate` to today, so saving an existing cash bonus re-scopes it to count from today (progress since the reward begins), not since records started.
- **Server actions** (`rewards/+page.server.ts`): `createConfig`/`updateConfig`/`createFromTemplate` now persist `completeBy`/`startDate`/`completeByDate`.
- **Typecheck/build**: `svelte-check` still at the 6 pre-existing canary errors (chores RewardType/Frequency, qrcode decl, settings unknown→string) — none in edited files; `pnpm build` (adapter-node) clean.
- **Ops note**: the schema fields are added to live PB by `ensureBonusFields()` on next `migrateOnBoot` (dev server restart). A restart of the dev frontend is needed to apply the migration + load the new code.
### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX) ### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX)
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning. - `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
-2
View File
@@ -38,7 +38,6 @@ export const handle: Handle = async ({ event, resolve }) => {
// Fam-user session: pb_token JWT → authRefresh → locals.user. // Fam-user session: pb_token JWT → authRefresh → locals.user.
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
if (token) { if (token) {
const pb = createPbClient(token); const pb = createPbClient(token);
try { try {
@@ -50,7 +49,6 @@ export const handle: Handle = async ({ event, resolve }) => {
// name/role/famId, since PB doesn't embed custom fields in the // name/role/famId, since PB doesn't embed custom fields in the
// token itself. // token itself.
const { record, token: freshToken } = await pb.collection('users').authRefresh(); const { record, token: freshToken } = await pb.collection('users').authRefresh();
event.locals.user = { event.locals.user = {
id: record.id, id: record.id,
name: record.name || record.username || '', name: record.name || record.username || '',
+4 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: { let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
href?: string; href?: string;
onclick?: () => void; onclick?: () => void;
@@ -46,4 +46,7 @@
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } .btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.btn-danger:hover { background: #b91c1c; } .btn-danger:hover { background: #b91c1c; }
.btn-success { background: #059669; color: #fff; border-color: #059669; }
.btn-success:hover { background: #047857; }
</style> </style>
+10 -6
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
let { cols = 3, children }: { cols?: number; children?: any } = $props(); let { stackTablet = false, children }: { stackTablet?: boolean; children?: any } = $props();
</script> </script>
<div class="card-grid" style="--grid-cols: {cols}"> <div class="card-grid" class:stack-tablet={stackTablet}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -10,18 +10,22 @@
.card-grid { .card-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(var(--grid-cols, 3), 1fr); grid-template-columns: repeat(3, 1fr);
} }
/* Tablet: settle to 2 columns */ /* Tablet: two columns */
@media (min-width: 640px) and (max-width: 1023px) { @media (min-width: 640px) and (max-width: 1023px) {
.card-grid { .card-grid {
--grid-cols: 2; grid-template-columns: repeat(2, 1fr);
}
/* Opt-in: stack to a single column on tablet */
.card-grid.stack-tablet {
grid-template-columns: 1fr;
} }
} }
/* Mobile: single column */ /* Mobile: single column */
@media (max-width: 639px) { @media (max-width: 639px) {
.card-grid { .card-grid {
--grid-cols: 1; grid-template-columns: 1fr;
} }
} }
</style> </style>
@@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import { notices, type NoticeType } from '$lib/stores/notices.svelte'; import { notices, type NoticeType } from '$lib/stores/notices.svelte';
import {
checkCircleIcon,
alertTriangleIcon,
xCircleIcon,
infoIcon
} from '$lib/components/icons';
function icon(type: NoticeType) { function icon(type: NoticeType) {
switch (type) { switch (type) {
case 'success': return '✅'; case 'success': return checkCircleIcon;
case 'warning': return '⚠️'; case 'warning': return alertTriangleIcon;
case 'error': return '❌'; case 'error': return xCircleIcon;
default: return 'ℹ️'; default: return infoIcon;
} }
} }
</script> </script>
@@ -16,7 +22,7 @@
{#each notices.list as notice (notice.id)} {#each notices.list as notice (notice.id)}
<div class="notice notice-{notice.type}"> <div class="notice notice-{notice.type}">
<div class="notice-content"> <div class="notice-content">
<div class="notice-icon">{icon(notice.type)}</div> <div class="notice-icon">{@html icon(notice.type)}</div>
<div class="notice-text"> <div class="notice-text">
<h4>{notice.title}</h4> <h4>{notice.title}</h4>
{#if notice.message} {#if notice.message}
@@ -73,6 +79,14 @@
font-size: 1.1rem; font-size: 1.1rem;
flex-shrink: 0; flex-shrink: 0;
} }
.notice-icon :global(svg) {
width: 1.25rem;
height: 1.25rem;
}
.notice-success .notice-icon { color: #059669; }
.notice-warning .notice-icon { color: #d97706; }
.notice-error .notice-icon { color: #dc2626; }
.notice-info .notice-icon { color: #6366f1; }
.notice-text h4 { .notice-text h4 {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-size: 0.9rem; font-size: 0.9rem;
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { checkCircleIcon } from '$lib/components/icons';
interface Tier { interface Tier {
id: 'trial' | 'monthly' | 'yearly'; id: 'trial' | 'monthly' | 'yearly';
@@ -84,7 +85,7 @@
<ul> <ul>
{#each tier.features as f} {#each tier.features as f}
<li><span class="tick">✓</span>{f}</li> <li><span class="tick">{@html checkCircleIcon}</span>{f}</li>
{/each} {/each}
</ul> </ul>
</article> </article>
@@ -202,6 +203,7 @@
gap: 0.45rem; gap: 0.45rem;
} }
.tick { color: #059669; font-weight: 700; flex-shrink: 0; } .tick { color: #059669; font-weight: 700; flex-shrink: 0; }
.tick :global(svg) { width: 1.05em; height: 1.05em; vertical-align: -0.15em; }
.form-error { .form-error {
grid-column: 1 / -1; grid-column: 1 / -1;
+31 -34
View File
@@ -3,8 +3,8 @@
import { import {
dashboardIcon, dashboardIcon,
choresIcon, choresIcon,
rewardsIcon, archiveIcon,
bonusesIcon, gemIcon,
homeIcon, homeIcon,
settingsIcon, settingsIcon,
logoutIcon, logoutIcon,
@@ -13,15 +13,9 @@
let { famName = '', session = null, isParent = false, role = 'child' } = $props(); let { famName = '', session = null, isParent = false, role = 'child' } = $props();
let collapsed = $state(false);
let famSlug = $derived(page.data.famSlug ?? page.params.fam); let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || ''); let memberName = $derived(session?.memberName || page.params.username || '');
function toggle() {
collapsed = !collapsed;
}
let showChildItems = $derived(!!memberName); let showChildItems = $derived(!!memberName);
let navItems = $derived( let navItems = $derived(
@@ -30,8 +24,8 @@
{ href: `/${famSlug}`, label: famName, icon: homeIcon }, { href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }, { href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon }, { href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon }, { href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: gemIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon } { href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: archiveIcon }
] ]
: showChildItems : showChildItems
? [ ? [
@@ -49,28 +43,24 @@
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
: []), : []),
{ {
href: session ? '/logout' : '/login', href: session ? '/logout' : role === 'child' ? `/${famSlug}/join` : '/login',
label: session ? 'Log out' : 'Log in', label: session ? 'Log out' : 'Log in',
icon: logoutIcon icon: logoutIcon
} }
]); ]);
</script> </script>
<aside class="sidebar" class:collapsed> <aside class="sidebar">
<button class="toggle-btn" onclick={toggle}>
{collapsed ? '☰' : '✕'}
</button>
<div class="sidebar-header"> <div class="sidebar-header">
<span class="app-icon">✦</span> <span class="app-icon">✦</span>
{#if !collapsed}<span class="app-name">FamChore</span>{/if} <span class="app-name">FamChore</span>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
{#each navItems as item} {#each navItems as item}
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}> <a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
{@html item.icon} {@html item.icon}
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if} <span class="nav-label">{item.label}</span>
</a> </a>
{/each} {/each}
</nav> </nav>
@@ -79,7 +69,7 @@
{#each footerItems as item} {#each footerItems as item}
<a href={item.href} class="nav-item"> <a href={item.href} class="nav-item">
{@html item.icon} {@html item.icon}
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if} <span class="nav-label">{item.label}</span>
</a> </a>
{/each} {/each}
</div> </div>
@@ -100,21 +90,7 @@
z-index: 100; z-index: 100;
overflow: hidden; overflow: hidden;
} }
.sidebar.collapsed {
width: 56px;
}
.toggle-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: none;
border: none;
color: #a5b4fc;
font-size: 1.1rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.sidebar-header { .sidebar-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -171,4 +147,25 @@
.nav-label { .nav-label {
overflow: hidden; overflow: hidden;
} }
@media (max-width: 1023px) {
.sidebar {
width: 56px;
}
.sidebar-header {
justify-content: center;
padding: 1rem 0;
}
.app-name {
display: none;
}
.nav-item {
justify-content: center;
padding: 0.6rem 0;
margin: 0;
}
.nav-label {
display: none;
}
}
</style> </style>
@@ -49,6 +49,11 @@
transition: left 0.2s; transition: left 0.2s;
gap: 0.75rem; gap: 0.75rem;
} }
@media (max-width: 1023px) {
.topnav {
left: 56px;
}
}
.topnav-left { display: flex; align-items: center; gap: 0.5rem; } .topnav-left { display: flex; align-items: center; gap: 0.5rem; }
.topnav-announcement { flex: 1; text-align: center; } .topnav-announcement { flex: 1; text-align: center; }
.announcement-text { font-size: 0.85rem; color: #6b7280; } .announcement-text { font-size: 0.85rem; color: #6b7280; }
+138 -35
View File
@@ -1,20 +1,40 @@
<script lang="ts"> <script lang="ts">
let { title, subtitle, hero = false, tabs, weeknav, sort }: { import type { Snippet } from 'svelte';
let {
title,
subtitle,
hero = false,
tabs,
weeknav,
sort,
children
}: {
title: string; title: string;
subtitle?: string; subtitle?: string;
hero?: boolean; hero?: boolean;
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void }; tabs?: {
items: { label: string; value: string }[];
active: string;
onchange: (v: string) => void;
};
weeknav?: { current: string; onPrev: () => void; onNext: () => void }; weeknav?: { current: string; onPrev: () => void; onNext: () => void };
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void }; sort?: {
options: { label: string; value: string }[];
active: string;
onchange: (v: string) => void;
};
children?: Snippet;
} = $props(); } = $props();
</script> </script>
<div class="view-header" class:hero> <div class="view-header flex justify-between" class:hero>
<div class="view-title-group"> <div class="view-title-group">
<h1 class="view-title">{title}</h1> <h1 class="view-title text-3xl">{title}</h1>
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if} {#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
</div> </div>
{@render children?.()}
<div class="view-tools"> <div class="view-tools">
{#if tabs} {#if tabs}
<div class="tab-bar"> <div class="tab-bar">
@@ -22,8 +42,8 @@
<button <button
class="tab-btn" class="tab-btn"
class:active={tab.value === tabs.active} class:active={tab.value === tabs.active}
onclick={() => tabs.onchange(tab.value)} onclick={() => tabs.onchange(tab.value)}>{tab.label}</button
>{tab.label}</button> >
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -37,7 +57,11 @@
{/if} {/if}
{#if sort} {#if sort}
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}> <select
class="sort-select"
value={sort.active}
onchange={(e) => sort.onchange(e.currentTarget.value)}
>
{#each sort.options as opt} {#each sort.options as opt}
<option value={opt.value}>{opt.label}</option> <option value={opt.value}>{opt.label}</option>
{/each} {/each}
@@ -47,27 +71,80 @@
</div> </div>
<style> <style>
.view-header { margin-bottom: 1.5rem; } .view-header {
.view-title-group { margin-bottom: 0.5rem; } margin-bottom: 1.5rem;
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; } }
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; } .view-title-group {
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } margin-bottom: 0.5rem;
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; } }
.view-title {
font-weight: 700;
color: #111827;
margin: 0;
}
.view-subtitle {
font-size: 0.9rem;
color: #6b7280;
margin: 0.25rem 0 0 0;
}
.view-tools {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.tab-bar {
display: flex;
gap: 2px;
background: #f3f4f6;
border-radius: 6px;
padding: 2px;
}
.tab-btn { .tab-btn {
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px; padding: 0.35rem 0.85rem;
font-size: 0.85rem; color: #6b7280; cursor: pointer; border: none;
background: transparent;
border-radius: 5px;
font-size: 0.85rem;
color: #6b7280;
cursor: pointer;
}
.tab-btn.active {
background: #fff;
color: #4338ca;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.week-nav {
display: flex;
align-items: center;
gap: 0.5rem;
} }
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
.nav-btn { .nav-btn {
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px; padding: 0.2rem 0.5rem;
cursor: pointer; font-size: 1rem; line-height: 1; border: 1px solid #d1d5db;
background: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.nav-btn:hover {
background: #f3f4f6;
}
.nav-label {
font-size: 0.85rem;
color: #374151;
font-weight: 500;
min-width: 140px;
text-align: center;
} }
.nav-btn:hover { background: #f3f4f6; }
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
.sort-select { .sort-select {
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px; padding: 0.3rem 0.6rem;
font-size: 0.85rem; background: #fff; border: 1px solid #d1d5db;
border-radius: 5px;
font-size: 0.85rem;
background: #fff;
} }
/* ── Hero lead (child-dashboard style) ── */ /* ── Hero lead (child-dashboard style) ── */
@@ -78,15 +155,41 @@
color: #fff; color: #fff;
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35); box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
} }
.view-header.hero .view-title-group { margin-bottom: 0.25rem; } .view-header.hero .view-title-group {
.view-header.hero .view-title { color: #fff; } margin-bottom: 0.25rem;
.view-header.hero .view-subtitle { color: rgba(255, 255, 255, 0.85); } }
.view-header.hero .view-tools { margin-top: 0.75rem; } .view-header.hero .view-title {
.view-header.hero .tab-bar { background: rgba(255, 255, 255, 0.16); } color: #fff;
.view-header.hero .tab-btn { color: rgba(255, 255, 255, 0.9); } }
.view-header.hero .tab-btn:hover { color: #fff; } .view-header.hero .view-subtitle {
.view-header.hero .tab-btn.active { background: #fff; color: #4338ca; } color: rgba(255, 255, 255, 0.85);
.view-header.hero .nav-btn { background: rgba(255, 255, 255, 0.92); border-color: transparent; color: #4338ca; } }
.view-header.hero .nav-label { color: #fff; } .view-header.hero .view-tools {
.view-header.hero .sort-select { background: rgba(255, 255, 255, 0.92); color: #1e1b4b; } margin-top: 0.75rem;
}
.view-header.hero .tab-bar {
background: rgba(255, 255, 255, 0.16);
}
.view-header.hero .tab-btn {
color: rgba(255, 255, 255, 0.9);
}
.view-header.hero .tab-btn:hover {
color: #fff;
}
.view-header.hero .tab-btn.active {
background: #fff;
color: #4338ca;
}
.view-header.hero .nav-btn {
background: rgba(255, 255, 255, 0.92);
border-color: transparent;
color: #4338ca;
}
.view-header.hero .nav-label {
color: #fff;
}
.view-header.hero .sort-select {
background: rgba(255, 255, 255, 0.92);
color: #1e1b4b;
}
</style> </style>
+21 -1
View File
@@ -1,7 +1,14 @@
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>' export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>' export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
export const bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>' export const moneyIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'
export const coinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>'
export const walletIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>'
export const circleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
export const handCoinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>'
export const gemIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>'
export const archiveIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>'
export const todoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>'
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>' export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>' export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
@@ -11,3 +18,16 @@ export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fil
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>' export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>' export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
export const checkCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'
export const revokeIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
export const targetIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>'
export const calendarIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'
export const sunIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
export const lockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'
export const clockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
export const giftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>'
export const chevronLeftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 6 -6 6 6 6"/></svg>'
export const chevronRightIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m9 6 6 6 -6 6"/></svg>'
export const alertTriangleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>'
export const xCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>'
export const infoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>'
+32 -8
View File
@@ -68,8 +68,12 @@ async function createRecord(collection: string, data: any): Promise<void> {
} }
} }
// Seed two default seasons (Holidays, Term time) per family so the family-admin // Seed a default season (Holidays) per family so the family-admin season
// season picker always has options. Idempotent by name. // picker always has at least one option. "Term time" is intentionally NOT
// auto-seeded — it's a UK-school-specific term that not every family wants,
// and force-seeding it meant a deleted "Term time" kept coming back on every
// boot. Families that want it can add it themselves.
// Only seeds when a family has zero seasons, so user deletions stick.
async function ensureDefaultSeasons(): Promise<void> { async function ensureDefaultSeasons(): Promise<void> {
const famsCol = await getCollection("fams"); const famsCol = await getCollection("fams");
if (!famsCol) return; if (!famsCol) return;
@@ -78,18 +82,19 @@ async function ensureDefaultSeasons(): Promise<void> {
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); });
const fams = (await famsRes.json()).items || []; const fams = (await famsRes.json()).items || [];
const defaults = [ const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }];
{ name: "Holidays", color: "#f59e0b", active: true },
{ name: "Term time", color: "#3b82f6", active: true },
];
for (const fam of fams) { for (const fam of fams) {
const sRes = await fetch( const sRes = await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
{ headers: { Authorization: `Bearer ${t}` } } { headers: { Authorization: `Bearer ${t}` } }
); );
const have = new Set(((await sRes.json()).items || []).map((s: any) => s.name)); const have = ((await sRes.json()).items || []) as any[];
// Only seed defaults for families that have no seasons at all. Seeding
// per-name (idempotent) forced a deleted default (e.g. "Term time") back
// on every boot; now a user who removes one keeps it removed as long as
// at least one season remains.
if (have.length === 0) {
for (const d of defaults) { for (const d of defaults) {
if (!have.has(d.name)) {
await createRecord("seasons", { famId: fam.id, ...d }); await createRecord("seasons", { famId: fam.id, ...d });
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`); console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
} }
@@ -435,6 +440,25 @@ async function ensureBonusFields(): Promise<void> {
fields.push({ name: "isPocketMoney", type: "bool", required: false }); fields.push({ name: "isPocketMoney", type: "bool", required: false });
changed = true; changed = true;
} }
// Standalone-reward progress window (bonus_configs only): how far progress
// counts (`completeBy`) and the window's start/custom-end dates.
if (name === "bonus_configs") {
if (!has("completeBy")) {
fields.push({
name: "completeBy", type: "select", required: false,
values: ["unlimited", "week", "custom"], maxSelect: 1,
});
changed = true;
}
if (!has("startDate")) {
fields.push({ name: "startDate", type: "text", required: false });
changed = true;
}
if (!has("completeByDate")) {
fields.push({ name: "completeByDate", type: "text", required: false });
changed = true;
}
}
// rewardValue must be optional so an unset pocket-money droplet can exist. // rewardValue must be optional so an unset pocket-money droplet can exist.
const rv = fields.find((f: any) => f.name === "rewardValue"); const rv = fields.find((f: any) => f.name === "rewardValue");
if (rv && rv.required) { if (rv && rv.required) {
+9 -13
View File
@@ -4,7 +4,9 @@ import {
periodStart, periodStart,
periodEnd, periodEnd,
nextPaydayAfter, nextPaydayAfter,
weekStart weekStart,
bonusWindow,
completionInWindow
} from '@shared/timezone'; } from '@shared/timezone';
import { famMeta } from './fam'; import { famMeta } from './fam';
@@ -55,13 +57,8 @@ export async function evaluateFam(pb: any, famId: string) {
for (const cfg of configs) { for (const cfg of configs) {
// Pocket money pauses until the parent sets an amount. // Pocket money pauses until the parent sets an amount.
if (cfg.isPocketMoney && !cfg.rewardValue) continue; if (cfg.isPocketMoney && !cfg.rewardValue) continue;
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : ''; const win = bonusWindow(cfg, paydayEval, tzEval);
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : ''; const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
const periodCompletions = cfg.period
? allCompletions.filter(
(c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd
)
: allCompletions;
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
let createdReward = false; let createdReward = false;
@@ -241,11 +238,10 @@ export async function progress(pb: any, famId: string) {
for (const cfg of configsData) { for (const cfg of configsData) {
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : ''; const win = bonusWindow(cfg, payday, tz);
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ''; const pStart = win.from;
const periodCompletions = cfg.period const pEnd = win.to;
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd) const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
: completionsList;
const progressRows: any[] = []; const progressRows: any[] = [];
+1 -9
View File
@@ -10,13 +10,6 @@ export async function myChores(pb: any, famId: string, memberId: string) {
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
]); ]);
const { payday, paydayTime, tz } = await famMeta(pb, famId); const { payday, paydayTime, tz } = await famMeta(pb, famId);
let settings: any = {};
try {
const s = await pb
.collection('settings')
.getFullList({ filter: `famId = '${famId}'` });
settings = s?.[0] || {};
} catch {}
return { return {
templates, templates,
assigned, assigned,
@@ -25,7 +18,6 @@ export async function myChores(pb: any, famId: string, memberId: string) {
bonusConfigs, bonusConfigs,
payday, payday,
paydayTime, paydayTime,
timezone: tz, timezone: tz
simulateEow: !!settings.simulateEow
}; };
} }
@@ -11,13 +11,6 @@ export async function myChores(pb: any, famId: string, memberId: string) {
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
]); ]);
const { payday, paydayTime, tz } = await famMeta(pb, famId); const { payday, paydayTime, tz } = await famMeta(pb, famId);
let settings: any = {};
try {
const s = await pb
.collection('settings')
.getFullList({ filter: `famId = '${famId}'` });
settings = s?.[0] || {};
} catch {}
return { return {
templates, templates,
assigned, assigned,
@@ -26,8 +19,7 @@ export async function myChores(pb: any, famId: string, memberId: string) {
bonusConfigs, bonusConfigs,
payday, payday,
paydayTime, paydayTime,
timezone: tz, timezone: tz
simulateEow: !!settings.simulateEow
}; };
} }
-143
View File
@@ -172,149 +172,6 @@ export async function weeklySummary(pb: any, famId: string) {
return { weekStart: ws, daysInWeek, summaries }; return { weekStart: ws, daysInWeek, summaries };
} }
export async function eowPreview(pb: any, famId: string) {
const { payday, tz } = await famMeta(pb, famId);
const ws = weekStart(payday, tz);
const we = periodEnd('weekly', ws);
const [members, assigned, completions, configs, rewards] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }),
pb
.collection('bonus_configs')
.getFullList({ filter: `famId = '${famId}' && status = 'active'` })
.catch(() => []),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => [])
]);
let rewardPointsList: any[] = [];
let rewardCashList: any[] = [];
try {
[rewardPointsList, rewardCashList] = await Promise.all([
pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
}),
pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
})
]);
} catch {}
const assignedList = assigned;
const completionsList = completions;
const summaries = members.map((m: any) => {
const mc = completionsList.filter((c: any) => c.memberId === m.id);
const weekPoints = mc.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
}, 0);
const weekMoney = mc.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === 'money' ? Number(ch.value) : 0);
}, 0);
const bonusPoints = rewardPointsList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const bonusMoney = rewardCashList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
return {
memberId: m.id,
memberName: m.name || m.username || m.id.slice(0, 6),
memberColor: m.color,
pointsEarned: weekPoints + bonusPoints,
moneyEarned: weekMoney + bonusMoney,
choresCompleted: mc.length,
bonusEarned: bonusPoints
};
});
const predictedRewards: any[] = [];
const existingRewards = rewards;
for (const cfg of configs) {
if (cfg.type === 'manual') continue;
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
const periodCompletions = cfg.period
? completionsList.filter(
(c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd
)
: completionsList;
const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id);
const tryEval = (sourceComps: any[]) => {
if (cfg.type === 'threshold')
return sourceComps.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
}, 0);
if (cfg.type === 'count') return sourceComps.length;
return 0;
};
if (cfg.target === 'individual') {
const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members;
for (const m of targets) {
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id));
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
memberName: m.name || m.id.slice(0, 6),
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
});
}
} else if (cfg.target === 'collaborative') {
if (cfgRewards.length) continue;
const allIds = members.map((m: any) => m.id);
const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId));
const current = tryEval(teamComps);
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
memberName: 'Everyone',
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
});
} else if (cfg.target === 'competitive') {
if (cfgRewards.length) continue;
const scored = members.map((m: any) => ({
memberId: m.id,
name: m.name,
current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id))
}));
const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue));
const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0);
const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0];
if (winner)
predictedRewards.push({
config: cfg.name,
memberName: winner.name,
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `winner ${winner.current} pts`
});
}
}
const nextWeekStart = addDaysStr(ws, 7);
return {
simulateEow: true,
weekStart: ws,
weekEnd: we,
nextWeekStart,
summaries,
predictedRewards,
completionsThisWeek: completionsList.length
};
}
export async function releaseWeek(pb: any, famId: string) { export async function releaseWeek(pb: any, famId: string) {
const { payday, paydayTime, tz } = await famMeta(pb, famId); const { payday, paydayTime, tz } = await famMeta(pb, famId);
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` }); const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
+4 -1
View File
@@ -7,6 +7,7 @@ import * as chatSvc from './chat';
import * as settingsSvc from './settings'; import * as settingsSvc from './settings';
import * as crudSvc from './crud'; import * as crudSvc from './crud';
import * as debugSvc from './debug'; import * as debugSvc from './debug';
import * as statsSvc from './stats';
export * from './chat'; export * from './chat';
export { famMeta } from './fam'; export { famMeta } from './fam';
@@ -35,7 +36,6 @@ export function createServices(
...(timezone !== undefined ? { timezone } : {}) ...(timezone !== undefined ? { timezone } : {})
}), }),
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId), weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
eowPreview: (famId: string) => famSvc.eowPreview(pb, famId),
payday: (famId: string) => famSvc.releaseWeek(pb, famId), payday: (famId: string) => famSvc.releaseWeek(pb, famId),
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId), completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid), getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
@@ -94,6 +94,9 @@ export function createServices(
}, },
debug: { debug: {
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days) generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
},
stats: {
family: (famId: string) => statsSvc.familyStats(pb, famId)
} }
}; };
} }
+2 -3
View File
@@ -8,14 +8,13 @@ function resolveServerTz(tz?: string): string {
export async function getSettings(pb: any, famId: string) { export async function getSettings(pb: any, famId: string) {
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` }); const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
const s = list?.[0] || {}; const s = list?.[0] || {};
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' }; return { webhookUrl: s.webhookUrl || '' };
} }
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) { export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` }); const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
const existing = list?.[0]; const existing = list?.[0];
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
if (data.simulateEow !== undefined) patch.simulateEow = !!data.simulateEow;
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl); if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
let s; let s;
if (existing) { if (existing) {
@@ -23,5 +22,5 @@ export async function updateSettings(pb: any, famId: string, data: Record<string
} else { } else {
s = await pb.collection('settings').create({ famId, ...patch }); s = await pb.collection('settings').create({ famId, ...patch });
} }
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' }; return { webhookUrl: s.webhookUrl || '' };
} }
+129
View File
@@ -0,0 +1,129 @@
import { addDaysStr, weekdayInTz, resolveTz } from '@shared/timezone';
import { famMeta } from './fam';
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
// parametric form of `weekStart()` so we can bucket historical weeks, not
// just the current one.
function weekStartFor(dateStr: string, payday: number, tz: string): string {
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
const back = (((wd - payday) % 7) + 7) % 7;
return addDaysStr(dateStr, -back);
}
export type WeeklyPoint = {
weekStart: string;
points: number;
cash: number;
chores: number;
};
export type MemberSeries = {
memberId: string;
memberName: string;
memberColor: string;
weeks: WeeklyPoint[];
};
export type FamilyStats = {
allTimePts: number;
allTimeCash: number;
allTimeChores: number;
series: MemberSeries[];
};
// Server-side lifetime + weekly aggregates. Scans full-history `completions`
// and `rewards` once here (filtered and summed on the server) so the client
// receives fixed-size pre-computed numbers instead of every historical row —
// this is what keeps the all-time totals + retrospective graph scalable.
export async function familyStats(pb: any, famId: string): Promise<FamilyStats> {
const { payday, tz } = await famMeta(pb, famId);
const [members, assigned, completions, rewards] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` })
]);
const assignedMap = new Map<string, any>(assigned.map((a: any) => [a.id, a]));
const memberPts: Record<string, number> = {};
const memberCash: Record<string, number> = {};
const memberChores: Record<string, number> = {};
for (const m of members) {
memberPts[m.id] = 0;
memberCash[m.id] = 0;
memberChores[m.id] = 0;
}
// Per-member weekly buckets (points/cash/chores), keyed by `memberId:weekStart`.
const weekMap = new Map<string, WeeklyPoint>();
function addWeek(memberId: string, weekStart: string, pts: number, cash: number, chores: number) {
const key = `${memberId}:${weekStart}`;
const w = weekMap.get(key);
if (w) {
w.points += pts;
w.cash += cash;
w.chores += chores;
} else {
weekMap.set(key, { weekStart, points: pts, cash, chores });
}
}
// Chore completions contribute their chore value (points/money) to the
// member's lifetime totals and their weekly bucket. Every completion counts
// as a chore done (regardless of type).
for (const c of completions) {
const chore = assignedMap.get(c.assignedChoreId);
const pts = chore?.type === 'points' ? Number(chore.value) || 0 : 0;
const cash = chore?.type === 'money' ? Number(chore.value) || 0 : 0;
const date = (c.date || '').slice(0, 10);
memberPts[c.memberId] = (memberPts[c.memberId] || 0) + pts;
memberCash[c.memberId] = (memberCash[c.memberId] || 0) + cash;
memberChores[c.memberId] = (memberChores[c.memberId] || 0) + 1;
if (date) addWeek(c.memberId, weekStartFor(date, payday, tz), pts, cash, 1);
}
// Money-type todos auto-create a matching cash reward when the todo is
// completed (see completions.toggle). That cash is already counted in the
// completion loop above, so skip those completion-linked rewards here to
// avoid double-counting the same earning event.
const autoRewardIds = new Set(
completions.map((c: any) => c.rewardId).filter((id?: string) => !!id)
);
// Claimed rewards (bonuses/triggers/pocket money) count toward lifetime
// earnings once issued. Unclaimed/requested stay "to chase" client-side.
for (const r of rewards) {
if (r.status !== 'claimed') continue;
if (autoRewardIds.has(r.id)) continue;
const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10);
if (r.rewardType === 'points') {
const v = Number(r.value) || 0;
memberPts[r.memberId] = (memberPts[r.memberId] || 0) + v;
if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), v, 0, 0);
} else if (r.rewardType === 'cash') {
const v = Number(r.value) || 0;
memberCash[r.memberId] = (memberCash[r.memberId] || 0) + v;
if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), 0, v, 0);
}
}
const series: MemberSeries[] = members.map((m: any) => ({
memberId: m.id,
memberName: m.name,
memberColor: m.color || '#6366f1',
weeks: [...weekMap.entries()]
.filter(([key]) => key.startsWith(m.id + ':'))
.map(([, w]) => w)
.sort((a, b) => a.weekStart.localeCompare(b.weekStart))
}));
return {
allTimePts: Object.values(memberPts).reduce((s, n) => s + n, 0),
allTimeCash: Object.values(memberCash).reduce((s, n) => s + n, 0),
allTimeChores: Object.values(memberChores).reduce((s, n) => s + n, 0),
series
};
}
+26
View File
@@ -0,0 +1,26 @@
export type Shortcut = {
famSlug: string;
famName: string;
userName: string; // '' for parents (no handle in URL)
};
const KEY = 'fam_shortcut';
export function recordShortcut(entry: Shortcut) {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(KEY, JSON.stringify(entry));
}
export function readShortcut(): Shortcut | null {
if (typeof localStorage === 'undefined') return null;
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Shortcut) : null;
} catch {
return null;
}
}
export function shortcutTarget(s: Shortcut): string {
return s.userName ? `/${s.famSlug}/${s.userName}` : `/${s.famSlug}`;
}
+37 -2
View File
@@ -40,6 +40,7 @@ class ChatStore {
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private lastSeenAt = 0; private lastSeenAt = 0;
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>(); private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
private audioCtx: AudioContext | null = null;
typingNames = $derived.by(() => { typingNames = $derived.by(() => {
const names: string[] = []; const names: string[] = [];
@@ -156,8 +157,9 @@ class ChatStore {
} }
// Stop showing their typing indicator once the message lands. // Stop showing their typing indicator once the message lands.
this.clearTyping(record.authorId); this.clearTyping(record.authorId);
if (record.authorId !== this.actorId && !this.open) { if (record.authorId !== this.actorId) {
this.unread++; if (!this.open) this.unread++;
this.playDunk();
} }
} }
@@ -184,6 +186,39 @@ class ChatStore {
if (this.typing[actorId]) this.removeTyping(actorId); if (this.typing[actorId]) this.removeTyping(actorId);
} }
// Short, low-volume "dunk" blip played when a message from someone else is
// broadcast in. Synthesised with the Web Audio API so there's no asset to
// ship. Browsers require a user gesture before audio can start, but chat
// messages only arrive after the user has interacted, so the context
// resumes fine. Failures are silently ignored.
private playDunk() {
if (typeof window === 'undefined' || typeof AudioContext === 'undefined') return;
try {
const Ctor = window.AudioContext || (window as any).webkitAudioContext;
if (!Ctor) return;
if (!this.audioCtx) this.audioCtx = new Ctor();
const ctx = this.audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
// Quick downward pitch sweep gives the soft "dun-nk" thud.
osc.frequency.setValueAtTime(190, now);
osc.frequency.exponentialRampToValueAtTime(70, now + 0.18);
// Low volume with a fast attack/decay envelope.
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
osc.connect(gain).connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.24);
} catch {
/* audio is best-effort */
}
}
private removeTyping(actorId: string) { private removeTyping(actorId: string) {
if (!this.typing[actorId]) return; if (!this.typing[actorId]) return;
const next = { ...this.typing }; const next = { ...this.typing };
+3 -1
View File
@@ -138,6 +138,9 @@ export interface BonusConfig {
period?: BonusPeriod; period?: BonusPeriod;
status: BonusStatus; status: BonusStatus;
isPocketMoney?: boolean; isPocketMoney?: boolean;
completeBy?: 'unlimited' | 'week' | 'custom';
startDate?: string;
completeByDate?: string;
created: string; created: string;
updated: string; updated: string;
} }
@@ -182,7 +185,6 @@ export interface Settings {
id: string; id: string;
famId: string; famId: string;
webhookUrl?: string; webhookUrl?: string;
simulateEow?: boolean;
} }
export interface ChatMessage { export interface ChatMessage {
+92 -8
View File
@@ -1,5 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons';
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
let shortcut = $state<(Shortcut & { target: string }) | null>(null);
onMount(() => {
const s = readShortcut();
if (s) shortcut = { ...s, target: shortcutTarget(s) };
});
</script> </script>
<svelte:head> <svelte:head>
@@ -11,6 +21,18 @@
</svelte:head> </svelte:head>
<main class="landing"> <main class="landing">
{#if shortcut}
<a class="return-chip" href={shortcut.target} title="Jump back into {shortcut.famName}">
<span class="return-icon">
{@html homeIcon}
<span class="return-dot"></span>
</span>
<span class="return-text">
<span class="return-fam">{shortcut.famName}</span>
<span class="return-sub">Jump back in {@html chevronRightIcon}</span>
</span>
</a>
{/if}
<!-- HERO --> <!-- HERO -->
<section class="hero"> <section class="hero">
<div class="hero-inner"> <div class="hero-inner">
@@ -23,9 +45,9 @@
automatically — no spreadsheets, no nagging. automatically — no spreadsheets, no nagging.
</p> </p>
<ul class="hero-ticks"> <ul class="hero-ticks">
<li>Set up in under a minute</li> <li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
<li>Kids join with a simple invite code</li> <li><span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code</li>
<li>Allowance lands on payday, automatically</li> <li><span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, automatically</li>
</ul> </ul>
</div> </div>
@@ -34,7 +56,7 @@
<p class="card-sub">Free to get going. Takes about a minute.</p> <p class="card-sub">Free to get going. Takes about a minute.</p>
<a class="submit" href="/signup">Create my family</a> <a class="submit" href="/signup">Create my family</a>
<p class="card-alt"> <p class="card-alt">
<a href="/pricing">See pricing →</a> <a href="/pricing">See pricing {@html chevronRightIcon}</a>
</p> </p>
<p class="card-alt"> <p class="card-alt">
Already have a family? <a href="/login">Log in</a> Already have a family? <a href="/login">Log in</a>
@@ -97,7 +119,7 @@
<div class="cta"> <div class="cta">
<p>Ready to make chores painless?</p> <p>Ready to make chores painless?</p>
<a class="cta-btn" href="/signup">Create your family</a> <a class="cta-btn" href="/signup">Create your family</a>
<p class="card-alt"><a href="/pricing">or see pricing →</a></p> <p class="card-alt"><a href="/pricing">or see pricing {@html chevronRightIcon}</a></p>
</div> </div>
</section> </section>
</main> </main>
@@ -110,6 +132,58 @@
color: #1f2937; color: #1f2937;
} }
/* RETURNING-USER SHORTCUT */
.return-chip {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 20;
display: flex;
align-items: center;
gap: 0.6rem;
width: fit-content;
padding: 0.45rem 0.9rem 0.45rem 0.55rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 999px;
text-decoration: none;
color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s, box-shadow 0.15s;
}
.return-chip:hover {
border-color: #c7d2fe;
box-shadow: 0 2px 10px rgba(67, 56, 202, 0.14);
}
.return-icon {
position: relative;
display: inline-flex;
color: #4338ca;
}
.return-dot {
position: absolute;
right: -3px;
bottom: -3px;
width: 11px;
height: 11px;
background: #22c55e;
border: 2px solid #fff;
border-radius: 50%;
}
.return-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.return-fam {
font-weight: 700;
font-size: 0.92rem;
}
.return-sub {
font-size: 0.72rem;
color: #6b7280;
}
/* HERO */ /* HERO */
.hero { .hero {
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%); background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
@@ -156,10 +230,20 @@
gap: 0.6rem; gap: 0.6rem;
} }
.hero-ticks li::before { .hero-ticks li::before {
content: "✓"; content: "";
color: #4338ca; }
font-weight: 700; .hero-ticks .tick {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
margin-right: 0.5rem; margin-right: 0.5rem;
color: #4338ca;
}
.hero-ticks .tick :global(svg) {
width: 1.1em;
height: 1.1em;
} }
/* SIGNUP CARD */ /* SIGNUP CARD */
+34 -5
View File
@@ -8,6 +8,7 @@
import { notices } from '$lib/stores/notices.svelte'; import { notices } from '$lib/stores/notices.svelte';
import { Sidebar, TopNav, Footer, Chat } from '$lib/components'; import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
import { chatIcon } from '$lib/components/icons'; import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut';
import type { Session } from '$lib/types'; import type { Session } from '$lib/types';
let { children, data } = $props(); let { children, data } = $props();
@@ -20,7 +21,8 @@
let disabled = $derived(!!data.famAccess?.disabled); let disabled = $derived(!!data.famAccess?.disabled);
let accessReason = $derived(data.famAccess?.reason || ''); let accessReason = $derived(data.famAccess?.reason || '');
// Settings stays usable while paused so admins can apply a code / manage billing. // Settings stays usable while paused so admins can apply a code / manage billing.
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings')); let hasAuth = $derived(!!data.session);
let locked = $derived((disabled && !page.url.pathname.endsWith('/settings')) || !hasAuth);
// ── Post-checkout activation (event-driven) ── // ── Post-checkout activation (event-driven) ──
// Landing with ?checkout=return: if the webhook has already landed we show // Landing with ?checkout=return: if the webhook has already landed we show
@@ -88,10 +90,16 @@
function accessMessage(reason: string, parent: boolean) { function accessMessage(reason: string, parent: boolean) {
const map: Record<string, string> = { const map: Record<string, string> = {
code_expired: parent ? 'Access paused — your code has expired. Add a new one.' : 'Access paused.', code_expired: parent
? 'Access paused — your code has expired. Add a new one.'
: 'Access paused.',
code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.', code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.',
subscription_inactive: parent ? 'Access paused — check your subscription payment.' : 'Access paused.', subscription_inactive: parent
canceled: parent ? 'Access paused — renew your subscription or add a code.' : 'Access paused.', ? 'Access paused — check your subscription payment.'
: 'Access paused.',
canceled: parent
? 'Access paused — renew your subscription or add a code.'
: 'Access paused.',
no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.' no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.'
}; };
return map[reason] || 'Access paused.'; return map[reason] || 'Access paused.';
@@ -102,6 +110,19 @@
let prevClaimedIds = new Set<string>(); let prevClaimedIds = new Set<string>();
let mounted = false; let mounted = false;
// Persist a returning-user shortcut (localStorage) whenever an authenticated
// session exists. $effect (not onMount) so it also fires when the session
// appears after the layout was already mounted — e.g. a member redeeming
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
$effect(() => {
if (!page.data.session) return;
recordShortcut({
famSlug: page.params.fam || '',
famName: (page.data.fam?.name as string) || page.params.fam || '',
userName: page.data.role === 'parent' ? '' : page.params.username || ''
});
});
$effect(() => { $effect(() => {
if (!session || !famStore.initialized) return; if (!session || !famStore.initialized) return;
const rewards = famStore.rewards; const rewards = famStore.rewards;
@@ -160,10 +181,13 @@
<main class="app-main"> <main class="app-main">
<div class="page-wrap"> <div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div> <div class="page-content" class:locked>{@render children()}</div>
{#if locked} {#if locked}
<div class="disabled-overlay"> <div class="disabled-overlay">
<div class="disabled-card"> <div class="disabled-card">
{#if activating} {#if !hasAuth}
<h2>You need to be logged in to view this page</h2>
{:else if activating}
<div class="spinner" aria-hidden="true"></div> <div class="spinner" aria-hidden="true"></div>
<strong>Activating your subscription…</strong> <strong>Activating your subscription…</strong>
<span>Payment received — this usually only takes a few seconds.</span> <span>Payment received — this usually only takes a few seconds.</span>
@@ -241,6 +265,11 @@
flex: 1; flex: 1;
transition: margin-left 0.2s; transition: margin-left 0.2s;
} }
@media (max-width: 1023px) {
.app-main {
margin-left: 56px;
}
}
.page-wrap { .page-wrap {
position: relative; position: relative;
min-height: 70vh; min-height: 70vh;
+4 -3
View File
@@ -9,13 +9,14 @@ export async function load(event) {
const s = createServices(pbUser(event), session); const s = createServices(pbUser(event), session);
try { try {
const [members, templates, assigned, summary] = await Promise.all([ const [members, assigned, summary, fam, stats] = await Promise.all([
s.crud.list('members', famId), s.crud.list('members', famId),
s.crud.list('chore-templates', famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', famId),
s.fam.weeklySummary(famId), s.fam.weeklySummary(famId),
s.fam.get(famId),
s.stats.family(famId)
]); ]);
return { members, templates, assigned, summary }; return { members, assigned, summary, fam, stats };
} catch { } catch {
return {}; return {};
} }
+423 -117
View File
@@ -5,28 +5,39 @@
import { ViewHeader, CardGrid, Card } from '$lib/components'; import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatDDMMYY } from '$lib/format'; import { formatDDMMYY } from '$lib/format';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
Chart.register(...registerables); Chart.register(...registerables);
let { data } = $props(); let { data } = $props();
let famSlug = $derived(page.data.famSlug ?? page.params.fam); let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let famTz = $derived(resolveTz(data.fam?.timezone || (page.data.fam as any)?.timezone || 'auto'));
let summary = $state(data.summary); let summary = $state(data.summary);
let members = $state(data.members || []); let members = $state(data.members || []);
let templates = $state(data.templates || []);
let assigned = $state(data.assigned || []); let assigned = $state(data.assigned || []);
let stats = $state(data.stats || { allTimePts: 0, allTimeCash: 0, allTimeChores: 0, series: [] });
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({}); let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
let donutCharts: Record<string, Chart> = {}; let donutCharts: Record<string, Chart> = {};
let choresCanvas: HTMLCanvasElement | undefined; let choresCanvas: HTMLCanvasElement | undefined;
let choresChart: Chart | undefined; let choresChart: Chart | undefined;
let trendCanvas = $state<HTMLCanvasElement | undefined>();
let trendChart: Chart | undefined;
// Line-graph metric view: cash | points | chores
let metric = $state<'cash' | 'points' | 'chores'>('points');
function captureCanvas(node: HTMLCanvasElement, memberId: string) { function captureCanvas(node: HTMLCanvasElement, memberId: string) {
donutCanvases[memberId] = node; donutCanvases[memberId] = node;
return { destroy() { delete donutCanvases[memberId]; } }; return {
destroy() {
delete donutCanvases[memberId];
}
};
} }
// ─── Derived tile stats ─── // ─── Derived tile stats (this week) ───
let tileMembers = $derived(members.length); let tileMembers = $derived(members.length);
let tilePts = $derived( let tilePts = $derived(
summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0 summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0
@@ -38,10 +49,109 @@
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0 summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
); );
// ─── All-time header stats (server-computed) ───
let allTimePts = $derived(Number(stats.allTimePts) || 0);
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
let allTimeChores = $derived(Number(stats.allTimeChores) || 0);
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
let trend = $derived.by(() => {
const series = Array.isArray(stats.series) ? stats.series : [];
const memberLines = series.filter((m: any) => m.memberId);
return memberLines;
});
function computeTrend(): { labels: string[]; datasets: any[] } {
const allWeeks = new Set<string>();
for (const m of trend) {
for (const w of m.weeks || []) allWeeks.add(w.weekStart);
}
const weeks = [...allWeeks].sort();
if (weeks.length === 0) return { labels: [], datasets: [] };
const latest = weeks[weeks.length - 1];
const earliest = weeks[0];
const spanDays = Math.max(
0,
Math.round((new Date(latest).getTime() - new Date(earliest).getTime()) / 86400000)
);
// 1 month if data spans <= ~1 month; grow a month at a time up to 3.
const months = spanDays <= 31 ? 1 : spanDays <= 62 ? 2 : 3;
const windowStart = latest ? addDaysStr(latest, -(months * 31)) : earliest;
const labels = weeks.filter((w) => w >= windowStart);
const valueFor = (m: any, w: string, met: string): number => {
const wk = (m.weeks || []).find((x: any) => x.weekStart === w);
if (!wk) return 0;
return Number(wk[met]) || 0;
};
return {
labels,
datasets: trend.map((m: any) => ({
label: m.memberName,
data: labels.map((w) => valueFor(m, w, metric)),
borderColor: m.memberColor || '#6366f1',
backgroundColor: m.memberColor || '#6366f1',
tension: 0.35,
fill: false,
pointRadius: 3,
borderWidth: 2
}))
};
}
function totalChoresFor(memberId: string) { function totalChoresFor(memberId: string) {
return assigned.filter((a: any) => a.memberId === memberId).length; return assigned.filter((a: any) => a.memberId === memberId).length;
} }
// Weekly completion potential for a member: each daily chore = 7 instances,
// each weekly/todo chore = 1. (The raw `s.totalChores` is a template count,
// which is why the old donut could read >100%.)
function weeklyPotentialFor(memberId: string) {
return assigned
.filter((a: any) => a.memberId === memberId)
.reduce((sum: number, a: any) => sum + (a.frequency === 'daily' ? 7 : 1), 0);
}
// Chores expected "today" = the member's daily chores (each due once today).
function todayPotentialFor(memberId: string) {
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
}
// Max points a full "all daily chores done" day is worth for a member.
function dailyPointsPotential(memberId: string) {
return assigned
.filter((a: any) => a.memberId === memberId && a.frequency === 'daily')
.reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0);
}
// Days elapsed in the current week (min 1), for a running daily average.
function daysElapsed(): number {
const days = summary?.daysInWeek || [];
const today = todayInTz(famTz);
return Math.max(1, days.filter((d: string) => d <= today).length);
}
// Average daily points from chore completions this week.
function avgDailyPointsFor(s: any): number {
const total = Object.values(s.dayPoints || {}).reduce(
(sum: number, v: any) => sum + (Number(v) || 0),
0
);
return total / daysElapsed();
}
// Today's completion count — tolerant of UTC vs fam-local date keys.
function todayDone(s: any): number {
if (!s.dayCompletions) return 0;
const utc = new Date().toISOString().slice(0, 10);
if (s.dayCompletions[utc] != null) return s.dayCompletions[utc];
const local = new Date().toLocaleDateString('en-CA');
return s.dayCompletions[local] || 0;
}
function distinctTrendWeeks(): number {
const set = new Set<string>();
for (const m of trend) for (const w of m.weeks || []) set.add(w.weekStart);
return set.size;
}
onMount(() => { onMount(() => {
if (!summary?.summaries?.length) return; if (!summary?.summaries?.length) return;
@@ -54,7 +164,7 @@
label: s.memberName, label: s.memberName,
data: days.map((d: string) => s.dayCompletions?.[d] || 0), data: days.map((d: string) => s.dayCompletions?.[d] || 0),
backgroundColor: s.memberColor || '#6366f1', backgroundColor: s.memberColor || '#6366f1',
borderRadius: 4, borderRadius: 4
})); }));
choresChart = new Chart(choresCanvas, { choresChart = new Chart(choresCanvas, {
type: 'bar', type: 'bar',
@@ -62,45 +172,122 @@
options: { options: {
responsive: true, responsive: true,
plugins: { plugins: {
legend: { display: summary.summaries.length > 1, position: 'bottom' }, legend: { display: summary.summaries.length > 1, position: 'bottom' }
}, },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
});
}
function makeDonut(canvas: HTMLCanvasElement, done: number, total: number, color: string) {
const remaining = Math.max(0, total - done);
return new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Done', 'Remaining'],
datasets: [
{
data: [done, remaining],
backgroundColor: [color || '#22c55e', '#e5e7eb'],
borderWidth: 0
}
]
}, },
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }
}); });
} }
for (const s of summary.summaries) { for (const s of summary.summaries) {
const canvas = donutCanvases[s.memberId]; const todayCanvas = donutCanvases[`${s.memberId}:today`];
if (!canvas) continue; if (todayCanvas) {
const done = s.choresCompleted || 0; donutCharts[`${s.memberId}:today`] = makeDonut(
const total = s.totalChores || 0; todayCanvas,
const remaining = Math.max(0, total - done); todayDone(s),
donutCharts[s.memberId] = new Chart(canvas, { todayPotentialFor(s.memberId),
type: 'doughnut', s.memberColor
data: { );
labels: ['Done', 'Remaining'], }
datasets: [{ const weekCanvas = donutCanvases[`${s.memberId}:week`];
data: [done, remaining], if (weekCanvas) {
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'], donutCharts[`${s.memberId}:week`] = makeDonut(
borderWidth: 0, weekCanvas,
}], s.choresCompleted || 0,
weeklyPotentialFor(s.memberId),
s.memberColor
);
}
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
if (scoreCanvas) {
donutCharts[`${s.memberId}:score`] = makeDonut(
scoreCanvas,
avgDailyPointsFor(s),
dailyPointsPotential(s.memberId),
s.memberColor
);
}
}
});
// Re-render the trend line chart when the metric view changes.
$effect(() => {
metric;
trend;
if (!trendCanvas) return;
const { labels, datasets } = computeTrend();
if (trendChart) {
trendChart.data.labels = labels;
trendChart.data.datasets = datasets;
trendChart.update();
} else if (datasets.length > 0) {
trendChart = new Chart(trendCanvas, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: trend.length > 1, position: 'bottom' },
tooltip: {
callbacks: {
label: (ctx: any) => {
const v = ctx.parsed.y;
if (metric === 'cash') return ` ${ctx.dataset.label}: £${Number(v).toFixed(2)}`;
if (metric === 'chores') return ` ${ctx.dataset.label}: ${v} chores`;
return ` ${ctx.dataset.label}: ${v} pts`;
}
}
}
}, },
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }, scales: { y: { beginAtZero: true } }
}
}); });
} }
}); });
onDestroy(() => { onDestroy(() => {
choresChart?.destroy(); choresChart?.destroy();
trendChart?.destroy();
for (const c of Object.values(donutCharts)) c?.destroy(); for (const c of Object.values(donutCharts)) c?.destroy();
}); });
</script> </script>
<ViewHeader <ViewHeader
title={famSlug} title={page.data.fam?.name || famSlug}
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'} subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
hero hero
/> >
<!-- All-time family totals, large in the header -->
<div class="vh-stats grow text-right">
<span class="vh-stat">
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
<span class="vh-lbl">points all time</span>
</span>
<span class="vh-stat">
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
<span class="vh-lbl">cash earned all time</span>
</span>
</div>
</ViewHeader>
<!-- STAT TILES --> <!-- STAT TILES -->
<div class="tiles"> <div class="tiles">
@@ -116,25 +303,44 @@
<span class="tile-val">£{tileMoney.toFixed(2)}</span> <span class="tile-val">£{tileMoney.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span> <span class="tile-lbl">cash this week</span>
</div> </div>
<div class="tile tile-chores">
<span class="tile-val">{tileChores}</span>
<span class="tile-lbl">chores done</span>
</div>
</div> </div>
<CardGrid> <CardGrid>
<!-- CHORES: this week | total -->
<Card cols={1} title="Chores">
<div class="chores-totals">
<div class="chore-total">
<span class="ct-val">{tileChores}</span>
<span class="ct-lbl">this week</span>
</div>
<div class="chore-total">
<span class="ct-val">{allTimeChores}</span>
<span class="ct-lbl">total all time</span>
</div>
</div>
<div class="chart-wrap">
<canvas bind:this={choresCanvas} height="200"></canvas>
</div>
</Card>
<!-- MEMBERS KANBAN --> <!-- MEMBERS KANBAN -->
<Card cols={3} scrollX title="Members"> <Card cols={2} scrollX title="Members">
{#if !summary?.summaries?.length} {#if !summary?.summaries?.length}
<p class="empty">No data yet this week</p> <p class="empty">No data yet this week</p>
{:else} {:else}
<div class="kanban"> <div class="kanban">
{#each summary.summaries as s} {#each summary.summaries as s}
{@const m = members.find((m2: any) => m2.id === s.memberId)} {@const m = members.find((m2: any) => m2.id === s.memberId)}
{@const total = totalChoresFor(s.memberId)} {@const wkTotal = weeklyPotentialFor(s.memberId)}
{@const pct = total > 0 ? Math.round((s.choresCompleted / total) * 100) : 0} {@const wkDone = s.choresCompleted || 0}
{@const wkPct = wkTotal > 0 ? Math.min(100, Math.round((wkDone / wkTotal) * 100)) : 0}
{@const tdTotal = todayPotentialFor(s.memberId)}
{@const tdDone = todayDone(s)}
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
{@const scPot = dailyPointsPotential(s.memberId)}
{@const scVal = avgDailyPointsFor(s)}
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
<div class="column col-member"> <div class="column col-member">
<div class="member-hero">
<div class="member-head"> <div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span> <span class="dot" style="background:{s.memberColor}"></span>
<span class="member-name">{s.memberName}</span> <span class="member-name">{s.memberName}</span>
@@ -142,9 +348,21 @@
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a> <a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
{/if} {/if}
</div> </div>
<div class="donuts">
<div class="donut-wrap"> <div class="donut-wrap">
<canvas use:captureCanvas={s.memberId} height="100" width="100"></canvas> <canvas use:captureCanvas={`${s.memberId}:today`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{pct}%</div> <div class="donut-center" style="color:{s.memberColor}">{tdPct}%</div>
<span class="donut-label">Today</span>
</div>
<div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:week`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{wkPct}%</div>
<span class="donut-label">Week</span>
</div>
<div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{scPct}%</div>
<span class="donut-label">Avg/day</span>
</div> </div>
</div> </div>
<div class="member-stats"> <div class="member-stats">
@@ -158,12 +376,7 @@
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-lbl">Done</span> <span class="stat-lbl">Done</span>
<span class="stat-val">{s.choresCompleted}/{total}</span> <span class="stat-val">{wkDone}/{wkTotal}</span>
</div>
</div>
<div class="progress-row">
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -172,44 +385,126 @@
{/if} {/if}
</Card> </Card>
<!-- CHORES PER DAY CHART --> <!-- WEEKLY TREND (cash / points / chores per member) -->
<Card cols={2} title="Chores per day"> <Card cols={3} title="Over time">
{#if summary?.summaries?.length} <div class="trend-tabs">
<button
class="trend-tab"
class:active={metric === 'points'}
onclick={() => (metric = 'points')}>Points</button
>
<button class="trend-tab" class:active={metric === 'cash'} onclick={() => (metric = 'cash')}
>Cash</button
>
<button
class="trend-tab"
class:active={metric === 'chores'}
onclick={() => (metric = 'chores')}>Chores</button
>
</div>
{#if distinctTrendWeeks() === 0}
<p class="empty">No weekly data yet</p>
{:else}
<div class="chart-wrap"> <div class="chart-wrap">
<canvas bind:this={choresCanvas} height="200"></canvas> <canvas bind:this={trendCanvas} height="220"></canvas>
</div>
{:else}
<p class="empty">No data yet</p>
{/if}
</Card>
<!-- TEMPLATES -->
<Card cols={1} title="Templates ({templates.length})">
{#if templates.length === 0}
<p class="empty">No templates created</p>
{:else}
<div class="template-list">
{#each templates as t}
<div class="template-chip">
<span class="tpl-name">{t.name}</span>
<span class="tpl-meta">{t.defaultFrequency} · {t.defaultValue} {t.defaultType}</span>
</div>
{/each}
</div> </div>
{/if} {/if}
</Card> </Card>
</CardGrid> </CardGrid>
<style> <style>
/* ── All-time header stats ── */
.vh-stats {
display: flex;
flex-direction: column;
}
.vh-stat {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.vh-num {
font-size: 2.4rem;
font-weight: 900;
line-height: 1;
letter-spacing: -0.02em;
}
.vh-num.pts {
color: #fff;
}
.vh-lbl {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.85;
}
/* ── Chores card totals ── */
.chores-totals {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
background: linear-gradient(135deg, #0284c7, #38bdf8);
border-radius: 14px;
padding: 0.9rem 1.1rem;
margin-bottom: 0.85rem;
color: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.chore-total {
display: inline-flex;
flex-direction: column;
}
.ct-val {
font-size: 1.6rem;
font-weight: 800;
color: #fff;
}
.ct-lbl {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.85);
}
.trend-tabs {
display: flex;
gap: 2px;
background: #f3f4f6;
border-radius: 6px;
padding: 2px;
margin-bottom: 0.85rem;
width: fit-content;
}
.trend-tab {
padding: 0.35rem 0.85rem;
border: none;
background: transparent;
border-radius: 5px;
font-size: 0.85rem;
color: #6b7280;
cursor: pointer;
}
.trend-tab.active {
background: #fff;
color: #4338ca;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* ── Tiles (matching child dashboard) ── */ /* ── Tiles (matching child dashboard) ── */
.tiles { .tiles {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.tiles { grid-template-columns: repeat(2, 1fr); } .tiles {
grid-template-columns: repeat(2, 1fr);
}
} }
.tile { .tile {
border-radius: 14px; border-radius: 14px;
@@ -220,11 +515,22 @@
gap: 0.15rem; gap: 0.15rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
} }
.tile-members { background: linear-gradient(135deg, #6366f1, #8b5cf6); } .tile-members {
.tile-pts { background: linear-gradient(135deg, #7c3aed, #a855f7); } background: linear-gradient(135deg, #6366f1, #8b5cf6);
.tile-cash { background: linear-gradient(135deg, #059669, #10b981); } }
.tile-chores { background: linear-gradient(135deg, #0284c7, #38bdf8); } .tile-pts {
.tile-val { font-size: 1.6rem; font-weight: 800; } background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.tile-cash {
background: linear-gradient(135deg, #059669, #10b981);
}
.tile-chores {
background: linear-gradient(135deg, #0284c7, #38bdf8);
}
.tile-val {
font-size: 1.6rem;
font-weight: 800;
}
.tile-lbl { .tile-lbl {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
@@ -246,14 +552,11 @@
padding: 0.75rem; padding: 0.75rem;
background: #fafbff; background: #fafbff;
} }
.col-member { border-top-color: #6366f1; background: #eef2ff; } .col-member {
border-top-color: #6366f1;
.member-hero { background: #eef2ff;
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
} }
.member-head { .member-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -263,7 +566,8 @@
} }
.dot { .dot {
display: inline-block; display: inline-block;
width: 10px; height: 10px; width: 10px;
height: 10px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -273,6 +577,8 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex: 1;
min-width: 0;
} }
.kanban-link { .kanban-link {
font-size: 0.75rem; font-size: 0.75rem;
@@ -281,15 +587,26 @@
font-weight: 600; font-weight: 600;
flex-shrink: 0; flex-shrink: 0;
} }
.kanban-link:hover { text-decoration: underline; } .kanban-link:hover {
text-decoration: underline;
}
.donuts {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.85rem;
margin: 3rem 0;
}
.donut-wrap { .donut-wrap {
position: relative; position: relative;
width: 70px; height: 70px; width: 62px;
height: 62px;
flex-shrink: 0; flex-shrink: 0;
} }
.donut-wrap canvas { .donut-wrap canvas {
max-width: 70px; max-height: 70px; max-width: 62px;
max-height: 62px;
} }
.donut-center { .donut-center {
position: absolute; position: absolute;
@@ -297,10 +614,22 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 800; font-weight: 800;
pointer-events: none; pointer-events: none;
} }
.donut-label {
position: absolute;
bottom: -15px;
left: 0;
right: 0;
text-align: center;
font-size: 0.6rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.member-stats { .member-stats {
display: flex; display: flex;
@@ -314,50 +643,27 @@
align-items: center; align-items: center;
font-size: 0.8rem; font-size: 0.8rem;
} }
.stat-lbl { color: #6b7280; font-weight: 500; } .stat-lbl {
.stat-val { font-weight: 700; } color: #6b7280;
.stat-val.pts { color: #7c3aed; } font-weight: 500;
.stat-val.cash { color: #059669; }
.progress-row {
margin-top: 0.2rem;
} }
.bar-wrap { .stat-val {
height: 8px; font-weight: 700;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
} }
.bar-fill { .stat-val.pts {
height: 100%; color: #7c3aed;
background: linear-gradient(90deg, #6366f1, #8b5cf6); }
border-radius: 4px; .stat-val.cash {
transition: width 0.3s ease; color: #059669;
} }
/* ── Chart ── */ /* ── Chart ── */
.chart-wrap { min-height: 200px; } .chart-wrap {
.chart-wrap canvas { max-height: 300px; } min-height: 200px;
/* ── Templates ── */
.template-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
} }
.template-chip { .chart-wrap canvas {
display: flex; max-height: 300px;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border: 1px solid #e5e7eb;
border-left: 4px solid #6366f1;
border-radius: 8px;
background: #fafbff;
gap: 0.5rem;
} }
.tpl-name { font-weight: 600; font-size: 0.85rem; }
.tpl-meta { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
/* ── Shared ── */ /* ── Shared ── */
.empty { .empty {
@@ -35,7 +35,7 @@ export async function load(event) {
s.crud.list('rewards', famId), s.crud.list('rewards', famId),
s.crud.list('bonus-configs', famId), s.crud.list('bonus-configs', famId),
s.crud.list('completions', famId), s.crud.list('completions', famId),
s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' })) s.settings.get(famId)
]); ]);
return { return {
role: 'parent', role: 'parent',
@@ -105,8 +105,7 @@ export async function load(event) {
tallies: {} as Record<string, unknown>, tallies: {} as Record<string, unknown>,
payday: chores.payday, payday: chores.payday,
paydayTime: chores.paydayTime || '18:00', paydayTime: chores.paydayTime || '18:00',
timezone: chores.timezone || 'auto', timezone: chores.timezone || 'auto'
simulateEow: !!chores.simulateEow
}; };
} catch { } catch {
return empty; return empty;
@@ -114,33 +113,6 @@ export async function load(event) {
} }
export const actions = { export const actions = {
setEow: async (event) => {
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const on = fd.get('on') === 'true';
try {
const s = createServices(pbUser(event), event.locals.user);
const result = await s.settings.update(famId, { simulateEow: on });
return { simulateEow: result.simulateEow };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
}
},
previewEow: async (event) => {
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
try {
const s = createServices(pbUser(event), event.locals.user);
const preview = await s.fam.eowPreview(famId);
await s.settings.update(famId, { simulateEow: true });
return { preview, simulateEow: true };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
}
},
claim: async (event) => { claim: async (event) => {
if (!event.locals.user) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId; const famId = event.locals.user.famId;
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,9 @@
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card } from '$lib/components'; import { ViewHeader, CardGrid, Card } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons';
import { formatHumanDate } from '$lib/format'; import { formatHumanDate } from '$lib/format';
import { nextPaydayAfter, todayInTz, resolveTz } from '@shared/timezone';
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons'; import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types'; import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
@@ -47,8 +49,26 @@
// Curated celebration emojis for todos // Curated celebration emojis for todos
const EMOJI_CHOICES = [ const EMOJI_CHOICES = [
'🎉', '⭐', '🏆', '💪', '🧹', '📚', '🦸', '✨', '🌟', '🥇', '🎉',
'🎯', '💚', '🚀', '🦄', '🌈', '🍀', '🎨', '🎸', '⚽', '🍰' '⭐',
'🏆',
'💪',
'🧹',
'📚',
'🦸',
'✨',
'🌟',
'🥇',
'🎯',
'💚',
'🚀',
'🦄',
'🌈',
'🍀',
'🎨',
'🎸',
'⚽',
'🍰'
]; ];
let editFreq = $state('daily'); let editFreq = $state('daily');
@@ -67,9 +87,11 @@
// Platform-owned templates only — families assign these, they don't create. // Platform-owned templates only — families assign these, they don't create.
let templates = $derived( let templates = $derived(
((famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])) || []).filter( (
(t: ChoreTemplate) => t.global (famStore.initialized
) ? (famStore.templates as ChoreTemplate[])
: (data.templates as ChoreTemplate[])) || []
).filter((t: ChoreTemplate) => t.global)
); );
let members = $state( let members = $state(
famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || [] famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || []
@@ -84,7 +106,7 @@
// Only active seasons are selectable for assignment / filtering. // Only active seasons are selectable for assignment / filtering.
let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false)); let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false));
let completions = $state((data.completions as Completion[]) || []); let completions = $state((data.completions as Completion[]) || []);
// $effect(() => console.log(seasons, activeSeasons));
let filteredAssigned = $derived( let filteredAssigned = $derived(
seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter)) seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter))
); );
@@ -207,13 +229,39 @@
} }
// Sort todos by completeBy (soonest first), then by startDate // Sort todos by completeBy (soonest first), then by startDate
function sortedTodosForMember(memberId: string): AssignedChore[] { function todoSort(a: AssignedChore, b: AssignedChore): number {
return todosForMember(memberId).sort((a, b) => {
const da = a.completeBy || '9999-99-99'; const da = a.completeBy || '9999-99-99';
const db = b.completeBy || '9999-99-99'; const db = b.completeBy || '9999-99-99';
if (da !== db) return da.localeCompare(db); if (da !== db) return da.localeCompare(db);
return (a.startDate || '').localeCompare(b.startDate || ''); return (a.startDate || '').localeCompare(b.startDate || '');
}); }
// Family payday anchor + resolved tz for week-boundary maths.
const famPayday = $derived(
(famStore.fam as any as { payday?: number })?.payday != null
? Number((famStore.fam as any).payday)
: 1
);
const famTz = $derived(resolveTz((famStore.fam as any)?.timezone || 'auto'));
// A completed todo stays visible (green "TBC completed") for the rest of the
// week it was completed; once the next payday rolls around (a new week
// starts) it drops off the admin Todos list.
function comprisesTodo(todo: AssignedChore): boolean {
if (!isTodoCompleted(todo.id)) return false;
const completion = completions.find((c: Completion) => c.assignedChoreId === todo.id);
const done = (completion?.date || '').slice(0, 10);
if (!done) return true;
const nextPayday = nextPaydayAfter(done, famPayday, famTz);
return todayInTz(famTz) >= nextPayday;
}
// All todos shown for a member — but once the week they were completed in is
// over (payday has passed) the completed todo is hidden from the list.
function visibleTodosForMember(memberId: string): AssignedChore[] {
return todosForMember(memberId)
.filter((a) => !comprisesTodo(a))
.sort(todoSort);
} }
// 7 days from today as YYYY-MM-DD for the todo modal default // 7 days from today as YYYY-MM-DD for the todo modal default
@@ -305,21 +353,12 @@
<p class="error">{form.error}</p> <p class="error">{form.error}</p>
{/if} {/if}
<CardGrid> <CardGrid stackTablet>
<!-- Templates Section (at top, like bonuses) --> <!-- Templates Section (at top, like bonuses) -->
<Card cols={3}> <Card cols={3}>
<div class="template-section"> <div class="template-section">
<div class="template-header"> <div class="template-header">
<h3>Templates — Drag to assign</h3> <h3>Templates — Drag to assign</h3>
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each activeSeasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
</div> </div>
<div class="template-list"> <div class="template-list">
{#each templates as t} {#each templates as t}
@@ -349,11 +388,22 @@
<!-- Member Chore Assignment Kanban (3-column grid) --> <!-- Member Chore Assignment Kanban (3-column grid) -->
<Card cols={3} scrollX> <Card cols={3} scrollX>
<div class="kanban-toolbar">
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each activeSeasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
</div>
<div class="kanban"> <div class="kanban">
{#each members as m} {#each members as m}
<div <div
class="column member-col" class="column member-col"
style="border-top-color: {m.color}" style="border-top-color: {m.color}; --mc: {m.color}"
ondragover={handleDragOver} ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)} ondrop={(e) => handleDrop(e, m.id)}
> >
@@ -363,16 +413,25 @@
</h3> </h3>
<!-- TODOS accordion wrapper --> <!-- TODOS accordion wrapper -->
<div class="accordion-wrapper accordion-todos"> <div
class="accordion-wrapper accordion-todos"
class:open={accordionState[m.id]?.todos ?? true}
>
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}> <button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span> <span class="accordion-title">
<span class="accordion-icon">{@html todoIcon}</span>
<span class="accordion-label">Todos</span>
</span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span >{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
</button> </button>
<span class="accordion-count" style="background:{m.color}"
>{visibleTodosForMember(m.id).length}</span
>
{#if accordionState[m.id]?.todos ?? true} {#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body"> <div class="accordion-body">
{#each sortedTodosForMember(m.id) as a} {#each visibleTodosForMember(m.id) as a}
{@const completed = isTodoCompleted(a.id)} {@const completed = isTodoCompleted(a.id)}
{@const urgency = todoUrgency(a)} {@const urgency = todoUrgency(a)}
<div <div
@@ -407,7 +466,8 @@
</div> </div>
</div> </div>
{#if completed} {#if completed}
<span class="todo-completed-badge">✅ TBC completed</span> <span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span
>
{:else} {:else}
<button <button
class="del-btn" class="del-btn"
@@ -433,13 +493,22 @@
</div> </div>
<!-- CHORES accordion wrapper --> <!-- CHORES accordion wrapper -->
<div class="accordion-wrapper accordion-chores"> <div
class="accordion-wrapper accordion-chores"
class:open={accordionState[m.id]?.chores ?? true}
>
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}> <button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span> <span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Chores</span>
</span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span >{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
</button> </button>
<span class="accordion-count" style="background:{m.color}"
>{assignedForMember(m.id).length}</span
>
{#if accordionState[m.id]?.chores ?? true} {#if accordionState[m.id]?.chores ?? true}
<div class="accordion-body"> <div class="accordion-body">
{#each assignedForMember(m.id) as a} {#each assignedForMember(m.id) as a}
@@ -449,17 +518,20 @@
onclick={() => openEdit(a)} onclick={() => openEdit(a)}
role="button" role="button"
tabindex="0" tabindex="0"
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(a.color)}" style="border-left-color:{outlineColor(a.color)}; background:{accentBg(
a.color
)}"
> >
<div class="card-body"> <div class="card-body">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} /> <TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong> <strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span> <span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#each a.seasonIds || [] as sid} {#each a.seasonIds || [] as sid}
<span class="badge season" style="border-color:{seasonColor(sid)}; color:{seasonColor(sid)}" <span
>{seasonName(sid)}</span class="season-dot"
> title={seasonName(sid)}
style="background:{seasonColor(sid)}"
></span>
{/each} {/each}
{#if (a.seasonIds || []).length === 0} {#if (a.seasonIds || []).length === 0}
<span class="badge season muted">All year</span> <span class="badge season muted">All year</span>
@@ -501,14 +573,16 @@
</CardGrid> </CardGrid>
<!-- Season pill selector (reused by assign + edit modals) --> <!-- Season pill selector (reused by assign + edit modals) -->
{#snippet seasonPills(list: Season[], selected: string[], set: (v: string[]) => void)} {#snippet seasonPills(list: Season[] = [], selected: string[] = [], set: (v: string[]) => void)}
<div class="season-pills"> <div class="season-pills" role="radiogroup" aria-label="Seasons">
<button <button
type="button" type="button"
class="season-pill" class="season-pill"
class:selected={selected.length === 0} class:selected={selected.length === 0}
style="--pill:#9ca3af" style="--pill:#9ca3af"
onclick={() => set([])}>All year</button role="radio"
aria-checked={selected.length === 0}
onclick={() => set([])}>Any</button
> >
{#each list as s} {#each list as s}
<button <button
@@ -516,6 +590,8 @@
class="season-pill" class="season-pill"
class:selected={selected[0] === s.id} class:selected={selected[0] === s.id}
style="--pill:{s.color}" style="--pill:{s.color}"
role="radio"
aria-checked={selected[0] === s.id}
onclick={() => set([s.id])}>{s.name}</button onclick={() => set([s.id])}>{s.name}</button
> >
{/each} {/each}
@@ -524,16 +600,25 @@
<!-- Assign from Template Modal --> <!-- Assign from Template Modal -->
{#if assignModal} {#if assignModal}
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) assignModal = null; }} role="presentation"> <div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) assignModal = null;
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Assign: {assignModal.t.name}</h3> <h3>Assign: {assignModal.t.name}</h3>
<p class="hint"> <p class="hint">
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)?.name}'s Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)
chores. Amend, then Assign. ?.name}'s chores. Amend, then Assign.
</p> </p>
<label>Name <input bind:value={assignName} placeholder="Task name" /></label> <label>Name <input bind:value={assignName} placeholder="Task name" /></label>
<label <label
>Description <input bind:value={assignDescription} placeholder="Optional description" /></label >Description <input
bind:value={assignDescription}
placeholder="Optional description"
/></label
> >
<div class="color-field"> <div class="color-field">
<span class="field-label">Colour</span> <span class="field-label">Colour</span>
@@ -548,7 +633,8 @@
? '#e5e7eb' ? '#e5e7eb'
: c.value}" : c.value}"
title={c.label} title={c.label}
onclick={() => (assignColor = c.value)}></button> onclick={() => (assignColor = c.value)}
></button>
{/each} {/each}
</div> </div>
</div> </div>
@@ -581,7 +667,7 @@
</label> </label>
<div class="season-field"> <div class="season-field">
<span class="field-label">Seasons</span> <span class="field-label">Seasons</span>
<seasonPills activeSeasons selected={assignSeasonIds} set={(v: string[]) => (assignSeasonIds = v)} /> {@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" onclick={() => (assignModal = null)}>Cancel</button> <button type="button" onclick={() => (assignModal = null)}>Cancel</button>
@@ -593,7 +679,13 @@
<!-- Edit Assignment Modal --> <!-- Edit Assignment Modal -->
{#if showEditModal && editingAssignment} {#if showEditModal && editingAssignment}
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) closeEdit(); }} role="presentation"> <div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) closeEdit();
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3> <h3>
{#if editingAssignment.isTodo} {#if editingAssignment.isTodo}
@@ -611,7 +703,11 @@
</label> </label>
<label <label
>Description >Description
<input name="description" bind:value={editDescription} placeholder="Optional description" /></label <input
name="description"
bind:value={editDescription}
placeholder="Optional description"
/></label
> >
{#if editingAssignment.isTodo} {#if editingAssignment.isTodo}
@@ -631,7 +727,14 @@
{:else if editType === 'money'} {:else if editType === 'money'}
<label> <label>
Cash amount (£) Cash amount (£)
<input name="value" type="number" step="0.01" min="0.01" bind:value={editValue} required /> <input
name="value"
type="number"
step="0.01"
min="0.01"
bind:value={editValue}
required
/>
</label> </label>
{:else} {:else}
<input type="hidden" name="emoji" value={editEmoji} /> <input type="hidden" name="emoji" value={editEmoji} />
@@ -640,7 +743,8 @@
<button <button
type="button" type="button"
class="link-btn" class="link-btn"
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}> onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
>
{showTodoEmojiPicker ? 'Hide' : 'Choose'} {showTodoEmojiPicker ? 'Hide' : 'Choose'}
</button> </button>
</div> </div>
@@ -683,12 +787,13 @@
? '#e5e7eb' ? '#e5e7eb'
: c.value}" : c.value}"
title={c.label} title={c.label}
onclick={() => (editColor = c.value)}></button> onclick={() => (editColor = c.value)}
></button>
{/each} {/each}
</div> </div>
<input type="hidden" name="color" value={editColor} /> <input type="hidden" name="color" value={editColor} />
</div> </div>
<label <label class="icon-row"
><span class="icon-preview" ><span class="icon-preview"
><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span ><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span
> >
@@ -717,7 +822,7 @@
</label> </label>
<div class="season-field"> <div class="season-field">
<span class="field-label">Seasons</span> <span class="field-label">Seasons</span>
<seasonPills activeSeasons selected={editSeasonIds} set={(v: string[]) => (editSeasonIds = v)} /> {@render seasonPills(seasons, editSeasonIds, (v: string[]) => (editSeasonIds = v))}
</div> </div>
{/if} {/if}
<div class="modal-actions"> <div class="modal-actions">
@@ -731,7 +836,13 @@
<!-- Add Todo Modal --> <!-- Add Todo Modal -->
{#if showTodoModal && todoMemberId} {#if showTodoModal && todoMemberId}
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) showTodoModal = false; }} role="presentation"> <div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) showTodoModal = false;
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3> <h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3>
<form <form
@@ -777,13 +888,24 @@
{:else if todoType === 'money'} {:else if todoType === 'money'}
<label> <label>
Cash amount (£) Cash amount (£)
<input name="value" type="number" step="0.01" min="0.01" bind:value={todoValue} required /> <input
name="value"
type="number"
step="0.01"
min="0.01"
bind:value={todoValue}
required
/>
</label> </label>
{:else} {:else}
<input type="hidden" name="emoji" value={todoEmoji} /> <input type="hidden" name="emoji" value={todoEmoji} />
<div class="emoji-field"> <div class="emoji-field">
<span class="emoji-preview">{todoEmoji}</span> <span class="emoji-preview">{todoEmoji}</span>
<button type="button" class="link-btn" onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}> <button
type="button"
class="link-btn"
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
>
{showTodoEmojiPicker ? 'Hide' : 'Choose'} {showTodoEmojiPicker ? 'Hide' : 'Choose'}
</button> </button>
</div> </div>
@@ -874,6 +996,14 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
} }
@media (max-width: 1023px) {
.template-list {
flex-direction: column;
}
.template-list .card.template {
width: 100%;
}
}
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */ /* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban { .kanban {
@@ -1038,6 +1168,11 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.kanban-toolbar {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.season-filter label { .season-filter label {
font-size: 0.8rem; font-size: 0.8rem;
color: #6b7280; color: #6b7280;
@@ -1059,6 +1194,14 @@
color: #9ca3af; color: #9ca3af;
border-color: #e5e7eb; border-color: #e5e7eb;
} }
.season-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
flex: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.card-meta { .card-meta {
font-size: 0.7rem; font-size: 0.7rem;
color: #9ca3af; color: #9ca3af;
@@ -1234,37 +1377,45 @@
/* ── Accordion wrappers ── */ /* ── Accordion wrappers ── */
.accordion-wrapper { .accordion-wrapper {
background: #6366f1; position: relative;
border-radius: 10px;
margin: 0.75rem 0 0.3rem; margin: 0.75rem 0 0.3rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25);
} }
.accordion-wrapper.accordion-todos { .accordion-wrapper:first-child {
background: #2e8b57; margin-top: 1.1rem;
box-shadow: 0 2px 6px rgba(46, 139, 87, 0.25);
} }
.accordion-head { .accordion-head {
width: 100%; width: 100%;
padding: 0.55rem 0.85rem; padding: 0.55rem 0.85rem;
border: none; border: none;
border-left: 4px solid rgba(0, 0, 0, 0.2); border-left: 4px solid rgba(0, 0, 0, 0.2);
background: transparent; background: var(--mc, #6366f1);
border-radius: 10px;
cursor: pointer; cursor: pointer;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
transition: filter 0.15s; transition: filter 0.15s;
} }
.accordion-wrapper.accordion-todos .accordion-head {
background: oklch(from var(--mc, #6366f1) calc(l + 0.08) c h);
}
.accordion-wrapper.open .accordion-head {
border-radius: 10px 10px 0 0;
}
.accordion-head:hover { .accordion-head:hover {
filter: brightness(1.1); filter: brightness(1.1);
} }
.accordion-body { .accordion-body {
padding: 0.5rem 0.65rem 0.65rem; padding: 0.5rem 0.65rem 0.65rem;
background: rgba(0, 0, 0, 0.08); background: oklch(from var(--mc, #6366f1) calc(l - 0.1) c h);
border-radius: 0 0 10px 10px;
}
.accordion-wrapper.accordion-todos .accordion-body {
background: oklch(from var(--mc, #6366f1) calc(l - 0.02) c h);
} }
.accordion-chevron { .accordion-chevron {
display: flex; display: flex;
@@ -1272,6 +1423,40 @@
flex-shrink: 0; flex-shrink: 0;
opacity: 0.8; opacity: 0.8;
} }
.accordion-icon {
display: inline-flex;
align-items: center;
}
.accordion-title {
display: flex;
align-items: center;
gap: 0.4rem;
}
.accordion-label {
font-size: 0.82rem;
font-weight: 700;
color: #fff;
}
.accordion-count {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #6366f1;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
z-index: 2;
}
/* ── Todo admin cards ── */ /* ── Todo admin cards ── */
.todo-admin-card { .todo-admin-card {
@@ -1393,6 +1578,13 @@
border-radius: 4px; border-radius: 4px;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 3px;
}
.todo-completed-badge :global(svg) {
width: 0.95em;
height: 0.95em;
} }
/* ── Points badge ── */ /* ── Points badge ── */
@@ -131,7 +131,7 @@
<div class="toast">{toast}</div> <div class="toast">{toast}</div>
{/if} {/if}
<CardGrid> <CardGrid stackTablet>
{#if activeTab === 'rewards'} {#if activeTab === 'rewards'}
<Card cols={3}> <Card cols={3}>
{#if rewards.length === 0} {#if rewards.length === 0}
@@ -119,6 +119,12 @@ export const actions = {
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
if (memberId) data.memberId = memberId; if (memberId) data.memberId = memberId;
const completeBy = fd.get('completeBy');
if (completeBy) data.completeBy = completeBy;
const startDate = fd.get('startDate');
if (startDate) data.startDate = startDate;
const completeByDate = fd.get('completeByDate');
if (completeByDate) data.completeByDate = completeByDate;
try { try {
const s = servicesFor(event); const s = servicesFor(event);
const record = await s.crud.create('bonus-configs', famId, data); const record = await s.crud.create('bonus-configs', famId, data);
@@ -157,6 +163,12 @@ export const actions = {
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
if (memberId !== null) data.memberId = memberId || null; if (memberId !== null) data.memberId = memberId || null;
const completeBy = fd.get('completeBy');
if (completeBy) data.completeBy = completeBy;
const startDate = fd.get('startDate');
if (startDate !== null) data.startDate = startDate || '';
const completeByDate = fd.get('completeByDate');
if (completeByDate !== null) data.completeByDate = completeByDate || '';
try { try {
const s = servicesFor(event); const s = servicesFor(event);
const record = await s.crud.update('bonus-configs', famId, id, data); const record = await s.crud.update('bonus-configs', famId, id, data);
@@ -199,7 +211,10 @@ export const actions = {
period: fd.get('period') || '', period: fd.get('period') || '',
memberId: fd.get('memberId') || '', memberId: fd.get('memberId') || '',
status, status,
isPocketMoney: !!fd.get('isPocketMoney') isPocketMoney: !!fd.get('isPocketMoney'),
completeBy: fd.get('completeBy') || 'unlimited',
startDate: fd.get('startDate') || '',
completeByDate: fd.get('completeByDate') || ''
}; };
if (data.occurrence === 'once') data.period = ''; if (data.occurrence === 'once') data.period = '';
try { try {
@@ -5,7 +5,23 @@
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone';
import { chevronRight } from '$lib/components/icons';
import Clock from '@lucide/svelte/icons/clock'; import Clock from '@lucide/svelte/icons/clock';
import Tag from '@lucide/svelte/icons/tag';
import UsersRound from '@lucide/svelte/icons/users-round';
import Target from '@lucide/svelte/icons/target';
import Settings2 from '@lucide/svelte/icons/settings-2';
import CalendarDays from '@lucide/svelte/icons/calendar-days';
import Coins from '@lucide/svelte/icons/coins';
import Gift from '@lucide/svelte/icons/gift';
import PoundSterling from '@lucide/svelte/icons/pound-sterling';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import Save from '@lucide/svelte/icons/save';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Crown from '@lucide/svelte/icons/crown';
import Ban from '@lucide/svelte/icons/ban';
import type { import type {
BonusConfig, BonusConfig,
BonusTemplate, BonusTemplate,
@@ -17,9 +33,31 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// Today in the family's timezone (used for the default start date of a
// standalone reward — progress must not count points from before it began).
function todayIso(): string {
const famTz = resolveTz((famStore.fam as any)?.timezone || 'auto');
return todayInTz(famTz);
}
// Numbered-circle marker used for the Who / How / What step tabs and field
// labels (SVG string, same style as lib/components/icons.ts).
function stepIcon(n: number): string {
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><text x="12" y="16" text-anchor="middle" font-size="12" font-family="inherit" fill="currentColor" stroke="none">${n}</text></svg>`;
}
let showEditModal = $state(false); let showEditModal = $state(false);
let editingConfig = $state<BonusConfig | null>(null); let editingConfig = $state<BonusConfig | null>(null);
let creatingFromTemplate = $state<BonusTemplate | null>(null); let creatingFromTemplate = $state<BonusTemplate | null>(null);
// Multi-step rule editor: 1 = who, 2 = how, 3 = what.
let step = $state(1);
// Kanban columns can collapse (accordion) so templates can be dragged onto
// the desired area on tablet/mobile without cramming three columns.
let collapsed = $state({ member: false, comp: false, collab: false });
function toggleCol(key: 'member' | 'comp' | 'collab') {
collapsed[key] = !collapsed[key];
}
let createVals = $state({ let createVals = $state({
name: '', name: '',
@@ -48,7 +86,10 @@
rewardValue: '100', rewardValue: '100',
criteriaValue: 10, criteriaValue: 10,
period: '' as string, period: '' as string,
startMode: 'today' as string startMode: 'today' as string,
completeBy: 'unlimited' as string,
startDate: '' as string,
completeByDate: '' as string
}); });
let configs = $derived( let configs = $derived(
@@ -71,8 +112,9 @@
let showPeriod = $derived(editVals.occurrence !== 'once'); let showPeriod = $derived(editVals.occurrence !== 'once');
let showCriteria = $derived(editVals.type !== 'manual'); let showCriteria = $derived(editVals.type !== 'manual');
let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent');
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent'); let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent');
// Standalone (no period) rewards can scope progress by completeBy + dates.
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global));
let allBonusConfigs = $derived( let allBonusConfigs = $derived(
@@ -149,8 +191,12 @@
rewardValue: tpl.rewardValue, rewardValue: tpl.rewardValue,
criteriaValue: tpl.criteriaValue || 10, criteriaValue: tpl.criteriaValue || 10,
period: tpl.occurrence === 'once' ? '' : tpl.period || '', period: tpl.occurrence === 'once' ? '' : tpl.period || '',
startMode: 'today' startMode: 'today',
completeBy: 'unlimited',
startDate: todayIso(),
completeByDate: ''
}; };
step = 1;
showEditModal = true; showEditModal = true;
} }
@@ -197,8 +243,12 @@
rewardValue: cfg.rewardValue, rewardValue: cfg.rewardValue,
criteriaValue: cfg.criteriaValue || 10, criteriaValue: cfg.criteriaValue || 10,
period: cfg.occurrence === 'once' ? '' : cfg.period || '', period: cfg.occurrence === 'once' ? '' : cfg.period || '',
startMode: cfg.status === 'disabled' ? 'disabled' : 'today' startMode: cfg.status === 'disabled' ? 'disabled' : 'today',
completeBy: cfg.completeBy || 'unlimited',
startDate: cfg.startDate || todayIso(),
completeByDate: cfg.completeByDate || ''
}; };
step = 1;
showEditModal = true; showEditModal = true;
} }
@@ -210,10 +260,10 @@
); );
</script> </script>
<ViewHeader title="Bonuses" hero /> <ViewHeader title="Rewards" hero />
{#if pocketNotice} {#if pocketNotice}
<CardGrid> <CardGrid stackTablet>
<Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}> <Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}>
<div class="pocket-notice"> <div class="pocket-notice">
<strong>Pocket Money</strong> <strong>Pocket Money</strong>
@@ -231,7 +281,7 @@
</CardGrid> </CardGrid>
{/if} {/if}
<CardGrid> <CardGrid stackTablet>
<Card cols={3}> <Card cols={3}>
<div class="template-section"> <div class="template-section">
<h3>Templates — Drag to assign</h3> <h3>Templates — Drag to assign</h3>
@@ -267,8 +317,12 @@
<Card cols={3} scrollX> <Card cols={3} scrollX>
<div class="kanban"> <div class="kanban">
<!-- Member Column --> <!-- Member Column -->
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}> <div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}>
<h3>Member</h3> <button class="col-header" type="button" onclick={() => toggleCol('member')}>
<span class="col-title">Member</span>
<span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span>
</button>
<div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg} {#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
<div <div
class="col-card" class="col-card"
@@ -334,10 +388,15 @@
<p class="empty">No bonuses</p> <p class="empty">No bonuses</p>
{/if} {/if}
</div> </div>
</div>
<!-- Competition Column --> <!-- Competition Column -->
<div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}> <div class="column comp-col" class:collapsed={collapsed.comp} ondragover={handleDragOver} ondrop={handleDropToCompetition}>
<h3>Competition</h3> <button class="col-header" type="button" onclick={() => toggleCol('comp')}>
<span class="col-title">Competition</span>
<span class="chev" class:open={!collapsed.comp}>{@html chevronRight}</span>
</button>
<div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
{@const prog = progressByConfigId.get(cfg.id)} {@const prog = progressByConfigId.get(cfg.id)}
<div <div
@@ -395,10 +454,15 @@
<p class="empty">No bonuses</p> <p class="empty">No bonuses</p>
{/if} {/if}
</div> </div>
</div>
<!-- Collaboration Column --> <!-- Collaboration Column -->
<div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}> <div class="column collab-col" class:collapsed={collapsed.collab} ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
<h3>Collaboration</h3> <button class="col-header" type="button" onclick={() => toggleCol('collab')}>
<span class="col-title">Collaboration</span>
<span class="chev" class:open={!collapsed.collab}>{@html chevronRight}</span>
</button>
<div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
{@const prog = progressByConfigId.get(cfg.id)} {@const prog = progressByConfigId.get(cfg.id)}
<div <div
@@ -459,6 +523,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
<!-- Edit / Create-from-Template Modal --> <!-- Edit / Create-from-Template Modal -->
{#if showEditModal && (editingConfig || creatingFromTemplate)} {#if showEditModal && (editingConfig || creatingFromTemplate)}
@@ -473,9 +538,32 @@
role="presentation" role="presentation"
> >
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3> <h3 class="modal-title">
{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name} {creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}
</h3> </h3>
<!-- Step tabs -->
<div class="step-tabs" role="tablist">
<button
type="button"
class="step-tab"
class:active={step === 1}
onclick={() => (step = 1)}
>{@html stepIcon(1)}<span>Who</span></button>
<button
type="button"
class="step-tab"
class:active={step === 2}
onclick={() => (step = 2)}
>{@html stepIcon(2)}<span>How</span></button>
<button
type="button"
class="step-tab"
class:active={step === 3}
onclick={() => (step = 3)}
>{@html stepIcon(3)}<span>What</span></button>
</div>
<form <form
method="POST" method="POST"
action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'} action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'}
@@ -487,11 +575,20 @@
{#if creatingFromTemplate} {#if creatingFromTemplate}
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} /> <input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} />
{/if} {/if}
<label>Name <input name="name" bind:value={editVals.name} required /></label>
<label>Description <input name="description" bind:value={editVals.description} /></label <!-- STEP 1 — Who (basics) -->
> {#if step === 1}
<label <div class="step-body">
>Target <label>
<span class="field-label">{@html stepIcon(1)} Name</span>
<input name="name" bind:value={editVals.name} required />
</label>
<label>
<span class="field-label">{@html stepIcon(1)} Description</span>
<input name="description" bind:value={editVals.description} />
</label>
<label>
<span class="field-label"><UsersRound size={14} /> Target (who)</span>
<select name="target" bind:value={editVals.target}> <select name="target" bind:value={editVals.target}>
<option value="individual">Individual</option> <option value="individual">Individual</option>
<option value="competitive">Competition</option> <option value="competitive">Competition</option>
@@ -499,8 +596,8 @@
</select> </select>
</label> </label>
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'} {#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
<label <label>
>Member <span class="field-label"><UsersRound size={14} /> Member</span>
<select name="memberId" bind:value={editVals.memberId}> <select name="memberId" bind:value={editVals.memberId}>
<option value="">All (everyone)</option> <option value="">All (everyone)</option>
{#each members as m} {#each members as m}
@@ -509,8 +606,14 @@
</select> </select>
</label> </label>
{/if} {/if}
<label </div>
>Type {/if}
<!-- STEP 2 — How (rules + progress) -->
{#if step === 2}
<div class="step-body">
<label>
<span class="field-label"><Settings2 size={14} /> Type</span>
<select name="type" bind:value={editVals.type}> <select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold</option> <option value="threshold">Threshold</option>
<option value="count">Count</option> <option value="count">Count</option>
@@ -518,8 +621,8 @@
</select> </select>
</label> </label>
{#if editVals.type === 'threshold'} {#if editVals.type === 'threshold'}
<label <label>
>Threshold by <span class="field-label"><Target size={14} /> Threshold by</span>
<select name="thresholdType" bind:value={editVals.thresholdType}> <select name="thresholdType" bind:value={editVals.thresholdType}>
<option value="points">Points</option> <option value="points">Points</option>
<option value="percent">% of chores done</option> <option value="percent">% of chores done</option>
@@ -528,8 +631,8 @@
{/if} {/if}
{#if showCriteria} {#if showCriteria}
{#if editIsPercent} {#if editIsPercent}
<label <label>
>Chores completed (%) <span class="field-label"><Target size={14} /> Chores completed (%)</span>
<input <input
type="range" type="range"
min="0" min="0"
@@ -540,8 +643,8 @@
<span class="hint">{editVals.criteriaValue}% of chores this week</span> <span class="hint">{editVals.criteriaValue}% of chores this week</span>
</label> </label>
{:else} {:else}
<label <label>
>Target Value <span class="field-label"><Target size={14} /> Target Value</span>
<input <input
type="number" type="number"
name="criteriaValue" name="criteriaValue"
@@ -558,34 +661,102 @@
</label> </label>
{/if} {/if}
{/if} {/if}
<label <label>
>Occurrence <span class="field-label"><Settings2 size={14} /> Occurrence</span>
<select name="occurrence" bind:value={editVals.occurrence}> <select name="occurrence" bind:value={editVals.occurrence}>
<option value="recurring">Recurring</option> <option value="recurring">Recurring</option>
<option value="once">Once</option> <option value="once">Once</option>
</select> </select>
</label> </label>
{#if showPeriod} {#if showPeriod}
<label <label>
>Period <span class="field-label"><CalendarDays size={14} /> Period</span>
<select name="period" bind:value={editVals.period}> <select name="period" bind:value={editVals.period}>
<option value="">None</option> <option value="">None</option>
<option value="daily">Daily</option> <option value="daily">Daily</option>
<option value="weekly">Weekly</option> <option value="weekly">Weekly</option>
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</select> </select>
<span class="hint"
>Recurring bonuses reset and re-earn each period. Leave as "None" for a
one-off reward measured once.</span
>
</label> </label>
{/if} {/if}
{#if showProgressWindow}
<div class="start-mode">
<label <label
>Reward Type ><span class="field-label"><CalendarDays size={14} /> Progress counts</span
></label>
<div class="radio-group">
<label>
<input
type="radio"
name="completeBy"
value="unlimited"
bind:group={editVals.completeBy}
/>
<span>Unlimited (until reached)</span>
</label>
<label>
<input
type="radio"
name="completeBy"
value="custom"
bind:group={editVals.completeBy}
/>
<span>Until a date</span>
</label>
</div>
{#if editVals.completeBy === 'custom'}
<label>
<span class="field-label"><CalendarDays size={14} /> Complete by</span>
<input type="date" name="completeByDate" bind:value={editVals.completeByDate} />
</label>
{/if}
<label>
<span class="field-label"><CalendarDays size={14} /> Progress starts on</span>
<input type="date" name="startDate" bind:value={editVals.startDate} />
<span class="hint"
>Points before this date won't count toward the reward.</span
>
</label>
</div>
{/if}
</div>
{/if}
<!-- STEP 3 — What (reward) -->
{#if step === 3}
<div class="step-body">
<label>
<span class="field-label">
{#if editVals.rewardType === 'cash'}
<PoundSterling size={14} />
{:else if editVals.rewardType === 'points'}
<Coins size={14} />
{:else}
<Gift size={14} />
{/if}
Reward Type
</span>
<select name="rewardType" bind:value={editVals.rewardType}> <select name="rewardType" bind:value={editVals.rewardType}>
<option value="points">Points</option> <option value="points">Points</option>
<option value="cash">Cash</option> <option value="cash">Cash</option>
<option value="prize">Prize</option> <option value="prize">Prize</option>
</select> </select>
</label> </label>
<label <label>
>Reward Value <span class="field-label">
{#if editVals.rewardType === 'cash'}
<PoundSterling size={14} />
{:else if editVals.rewardType === 'points'}
<Coins size={14} />
{:else}
<Gift size={14} />
{/if}
Reward Value
</span>
<input name="rewardValue" bind:value={editVals.rewardValue} required /> <input name="rewardValue" bind:value={editVals.rewardValue} required />
<span class="hint" <span class="hint"
>{editVals.rewardType === 'cash' >{editVals.rewardType === 'cash'
@@ -597,7 +768,9 @@
</label> </label>
{#if creatingFromTemplate} {#if creatingFromTemplate}
<div class="start-mode"> <div class="start-mode">
<label>When to start</label> <label
><span class="field-label"><CalendarDays size={14} /> When to start</span
></label>
<div class="radio-group"> <div class="radio-group">
<label> <label>
<input <input
@@ -614,6 +787,7 @@
name="startMode" name="startMode"
value="today" value="today"
bind:group={editVals.startMode} bind:group={editVals.startMode}
onchange={() => (editVals.startDate = todayIso())}
/> />
<span>Today</span> <span>Today</span>
</label> </label>
@@ -623,13 +797,29 @@
name="startMode" name="startMode"
value="nextWeek" value="nextWeek"
bind:group={editVals.startMode} bind:group={editVals.startMode}
onchange={() => (editVals.startDate = addDaysStr(todayIso(), 7))}
/> />
<span>Next week</span> <span>Next week</span>
</label> </label>
</div> </div>
</div> </div>
{/if} {/if}
</div>
{/if}
<!-- Footer actions -->
<div class="modal-actions"> <div class="modal-actions">
{#if step === 2}
<button type="button" class="nav-btn" onclick={() => (step = 1)}>
<ArrowLeft size={14} /> Back
</button>
<button type="button" class="next-btn" onclick={() => (step = 3)}>
Next <ArrowRight size={14} />
</button>
{:else if step === 3}
<button type="button" class="nav-btn" onclick={() => (step = 2)}>
<ArrowLeft size={14} /> Back
</button>
{#if editingConfig} {#if editingConfig}
<button <button
type="button" type="button"
@@ -643,6 +833,7 @@
if (res.ok) showEditModal = false; if (res.ok) showEditModal = false;
}} }}
> >
<Ban size={14} />
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'} {editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
</button> </button>
{#if editingConfig.status !== 'completed'} {#if editingConfig.status !== 'completed'}
@@ -658,7 +849,7 @@
if (res.ok) showEditModal = false; if (res.ok) showEditModal = false;
}} }}
> >
Mark Complete <Crown size={14} /> Complete
</button> </button>
{/if} {/if}
<button <button
@@ -676,10 +867,17 @@
} }
}} }}
> >
Delete <Trash2 size={14} /> Delete
</button>
{/if}
<button type="submit" class="save-btn">
<Save size={14} /> {creatingFromTemplate ? 'Assign' : 'Save'}
</button>
{:else}
<button type="button" class="next-btn" onclick={() => (step = 2)}>
Next <ArrowRight size={14} />
</button> </button>
{/if} {/if}
<button type="submit">{creatingFromTemplate ? 'Assign' : 'Save'}</button>
</div> </div>
</form> </form>
</div> </div>
@@ -721,11 +919,23 @@
cursor: grabbing; cursor: grabbing;
opacity: 0.8; opacity: 0.8;
} }
@media (max-width: 1023px) {
.template-list {
flex-direction: column;
}
.template-card {
width: 100%;
}
}
.kanban { .kanban {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.75rem; gap: 0.75rem;
min-width: 450px; }
@media (max-width: 1023px) {
.kanban {
grid-template-columns: 1fr;
}
} }
.column { .column {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
@@ -742,8 +952,40 @@
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.95rem; font-size: 0.95rem;
} }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0 0 0.5rem;
padding: 0;
background: none;
border: none;
font: inherit;
text-align: left;
cursor: pointer;
color: inherit;
}
.col-header .col-title {
font-size: 0.95rem;
font-weight: 700;
}
.chev {
display: inline-flex;
transition: transform 0.2s ease;
}
.chev.open {
transform: rotate(90deg);
}
.column.collapsed {
min-height: 0;
}
.column.collapsed .col-body {
display: none;
}
.card-head { .card-head {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@@ -999,6 +1241,7 @@
gap: 0.5rem; gap: 0.5rem;
margin-top: 1rem; margin-top: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
} }
.modal-actions button { .modal-actions button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@@ -1006,12 +1249,17 @@
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
} }
.modal-actions button.nav-btn,
.modal-actions button[type='button'] { .modal-actions button[type='button'] {
background: #e5e7eb; background: #e5e7eb;
color: #374151; color: #374151;
} }
.modal-actions button[type='submit'] { .modal-actions button.next-btn,
.modal-actions button[type='submit'].save-btn {
background: #6366f1; background: #6366f1;
color: white; color: white;
} }
@@ -1027,6 +1275,52 @@
background: #ef4444; background: #ef4444;
color: white; color: white;
} }
/* Step tabs (Who / How / What) */
.step-tabs {
display: flex;
gap: 2px;
background: #f3f4f6;
border-radius: 6px;
padding: 2px;
margin-bottom: 1rem;
}
.step-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.4rem 0.5rem;
border: none;
background: transparent;
border-radius: 5px;
font-size: 0.85rem;
color: #6b7280;
cursor: pointer;
}
.step-tab.active {
background: #fff;
color: #4338ca;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.step-tab :global(svg) {
flex: none;
}
.field-label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-weight: 500;
color: #374151;
}
.field-label :global(svg) {
flex: none;
color: #6366f1;
}
.step-body {
min-height: 120px;
}
.start-mode { .start-mode {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
@@ -1046,5 +1340,19 @@
gap: 0.25rem; gap: 0.25rem;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
flex-wrap: wrap;
}
/* Progress-counts date inputs must shrink rather than blow the label width. */
.start-mode input[type='date'] {
flex-shrink: 1;
min-width: 0;
}
.step-body input[type='date'] {
flex-shrink: 1;
min-width: 0;
}
.radio-group label input[type='date'] {
flex-shrink: 1;
min-width: 0;
} }
</style> </style>
+9 -1
View File
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { Button } from '$lib/components'; import { Button } from '$lib/components';
import { homeIcon } from '$lib/components/icons';
const famSlug = page.params.fam; const famSlug = page.params.fam;
let name = $state(''); let name = $state('');
@@ -14,7 +15,7 @@
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6"> <main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm"> <section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"> <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
🏠 <span class="home-badge">{@html homeIcon}</span>
</div> </div>
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1> <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500"> <p class="mt-1 text-sm text-slate-500">
@@ -63,3 +64,10 @@
</p> </p>
</section> </section>
</main> </main>
<style>
.home-badge :global(svg) {
width: 1.75rem;
height: 1.75rem;
}
</style>
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { Button } from '$lib/components'; import { Button } from '$lib/components';
import { homeIcon } from '$lib/components/icons';
const famSlug = page.params.fam; const famSlug = page.params.fam;
const username = page.params.username; const username = page.params.username;
@@ -14,7 +15,7 @@
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6"> <main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm"> <section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"> <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
🏠 <span class="home-badge">{@html homeIcon}</span>
</div> </div>
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1> <h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
<p class="mt-1 text-sm text-slate-500"> <p class="mt-1 text-sm text-slate-500">
@@ -55,3 +56,9 @@
</p> </p>
</section> </section>
</main> </main>
<style>
.home-badge :global(svg) {
width: 1.75rem;
height: 1.75rem;
}
</style>
+59 -19
View File
@@ -6,6 +6,7 @@
import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons'; import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import { homeIcon, chevronRightIcon } from '$lib/components/icons';
let { data, form } = $props(); let { data, form } = $props();
@@ -77,15 +78,11 @@
}; };
showTplModal = true; showTplModal = true;
} }
const allTemplates = $derived([
...(data.choreTemplates || []).map((t: any) => ({ ...t, kind: 'chore' as TplKind })),
...(data.bonusTemplates || []).map((t: any) => ({ ...t, kind: 'reward' as TplKind }))
]);
</script> </script>
<div class="shell"> <div class="shell">
<header class="topbar"> <header class="topbar">
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a> <a class="brand" href="/"><span class="brand-icon">{@html homeIcon}</span> <strong>FamChore</strong></a>
<nav> <nav>
{#if data.authenticated} {#if data.authenticated}
<form method="POST" action="?/logout" use:enhance> <form method="POST" action="?/logout" use:enhance>
@@ -311,7 +308,7 @@
<span class="badge none">None</span> <span class="badge none">None</span>
{/if} {/if}
</td> </td>
<td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View →</a></td> <td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View {@html chevronRightIcon}</a></td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -320,34 +317,30 @@
</CardGrid> </CardGrid>
<CardGrid> <CardGrid>
<Card cols={3} title="Templates" accent="#8b5cf6"> <Card cols={2} title="Chore Templates" accent="#2563eb">
<p class="muted">
Platform-owned chore &amp; reward droplets. Families assign these as read-only sources — they
can't create their own.
</p>
<div class="tpl-grid"> <div class="tpl-grid">
{#each allTemplates as t} {#each (data.choreTemplates || []) as t}
<div class="tpl-card" style="border-left-color:{t.color}"> <div class="tpl-card" style="border-left-color:{t.color}">
<div class="tpl-head"> <div class="tpl-head">
<TemplateIcon name={t.icon} size={20} color={t.color} /> <TemplateIcon name={t.icon} size={20} color={t.color} />
<strong>{t.name}</strong> <strong>{t.name}</strong>
</div> </div>
<div class="tpl-meta"> <div class="tpl-meta">
<span class="badge">{t.kind}</span> <span class="badge chore">{t.defaultFrequency}</span>
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if} <span class="badge">{t.defaultType}</span>
</div> </div>
<div class="tpl-actions"> <div class="tpl-actions">
<button class="link-btn" onclick={() => openEditTpl(t.kind, t)}>Edit</button> <button class="link-btn" onclick={() => openEditTpl('chore', t)}>Edit</button>
<form <form
method="POST" method="POST"
action="?/deleteTemplate" action="?/deleteTemplate"
use:enhance={() => { use:enhance={() => {
return async ({ result, update }) => { return async ({ update }) => {
update(); update();
}; };
}} }}
> >
<input type="hidden" name="kind" value={t.kind} /> <input type="hidden" name="kind" value="chore" />
<input type="hidden" name="id" value={t.id} /> <input type="hidden" name="id" value={t.id} />
<button <button
type="submit" type="submit"
@@ -360,12 +353,57 @@
</div> </div>
</div> </div>
{/each} {/each}
{#if allTemplates.length === 0} {#if (data.choreTemplates || []).length === 0}
<p class="muted">No templates yet.</p> <p class="muted">No chore templates yet.</p>
{/if} {/if}
</div> </div>
<div class="tpl-new"> <div class="tpl-new">
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button> <Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
</div>
</Card>
<Card cols={1} title="Reward Templates" accent="#8b5cf6">
<div class="tpl-grid">
{#each (data.bonusTemplates || []) as t}
<div class="tpl-card" style="border-left-color:{t.color}">
<div class="tpl-head">
<TemplateIcon name={t.icon} size={20} color={t.color} />
<strong>{t.name}</strong>
</div>
<div class="tpl-meta">
<span class="badge reward">{t.type}</span>
<span class="badge">{t.rewardType}</span>
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
</div>
<div class="tpl-actions">
<button class="link-btn" onclick={() => openEditTpl('reward', t)}>Edit</button>
<form
method="POST"
action="?/deleteTemplate"
use:enhance={() => {
return async ({ update }) => {
update();
};
}}
>
<input type="hidden" name="kind" value="reward" />
<input type="hidden" name="id" value={t.id} />
<button
type="submit"
class="link-btn danger"
onclick={(e) => {
if (!confirm('Delete this template?')) e.preventDefault();
}}>Delete</button
>
</form>
</div>
</div>
{/each}
{#if (data.bonusTemplates || []).length === 0}
<p class="muted">No reward templates yet.</p>
{/if}
</div>
<div class="tpl-new">
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button> <Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
</div> </div>
</Card> </Card>
@@ -978,6 +1016,8 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
} }
.badge.chore { background: #dbeafe; color: #1e40af; }
.badge.reward { background: #ede9fe; color: #6d28d9; }
.badge.pocket { background: #dcfce7; color: #166534; } .badge.pocket { background: #dcfce7; color: #166534; }
.check { display: flex; align-items: center; gap: 0.4rem; } .check { display: flex; align-items: center; gap: 0.4rem; }
+1 -3
View File
@@ -16,9 +16,7 @@ export const actions = {
let authResult: { token: string; record: any }; let authResult: { token: string; record: any };
try { try {
authResult = await createPbClient() authResult = await createPbClient().collection('users').authWithPassword(email, password);
.collection('users')
.authWithPassword(email, password);
} catch { } catch {
return fail(400, { error: 'Invalid email or password', email }); return fail(400, { error: 'Invalid email or password', email });
} }
+9 -3
View File
@@ -22,10 +22,10 @@
</label> </label>
<button type="submit">Log in</button> <button type="submit">Log in</button>
</form> </form>
<p class="alt"> <p class="alt">
Don't have a family yet? <a href="/signup">Create one</a> Don't have a family yet? <a href="/signup">Create one</a>
</p> </p>
<p>Trying to join as a child? Ask your parent to issue your QR code.</p>
</AuthShell> </AuthShell>
<style> <style>
@@ -63,7 +63,9 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
button:hover { background: #3730a3; } button:hover {
background: #3730a3;
}
.form-error { .form-error {
background: #fef2f2; background: #fef2f2;
color: #b91c1c; color: #b91c1c;
@@ -79,5 +81,9 @@
color: #6b7280; color: #6b7280;
text-align: center; text-align: center;
} }
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; } .alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
</style> </style>
+12 -4
View File
@@ -6,6 +6,7 @@
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js'; import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
import { Button, PricingPlans } from '$lib/components'; import { Button, PricingPlans } from '$lib/components';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import { homeIcon, chevronLeftIcon, chevronRightIcon } from '$lib/components/icons';
let { data, form } = $props(); let { data, form } = $props();
@@ -30,7 +31,8 @@
// use:enhance is two-stage: this factory receives submit params (no result // use:enhance is two-stage: this factory receives submit params (no result
// yet), and the RESOLVE function it returns receives { result, formData }. // yet), and the RESOLVE function it returns receives { result, formData }.
const handleChoose = () => const handleChoose =
() =>
async ({ result, formData }: any) => { async ({ result, formData }: any) => {
if (result?.type === 'success' && result.data) { if (result?.type === 'success' && result.data) {
const d = result.data as { clientSecret?: string }; const d = result.data as { clientSecret?: string };
@@ -58,7 +60,9 @@
<div class="shell"> <div class="shell">
<header class="topbar"> <header class="topbar">
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a> <a class="brand" href="/"
><span class="brand-icon">{@html homeIcon}</span> <strong>FamChore</strong></a
>
<nav> <nav>
<a href="/login" class="nav-link">Log in</a> <a href="/login" class="nav-link">Log in</a>
<a href="/signup" class="nav-cta">Get started</a> <a href="/signup" class="nav-cta">Get started</a>
@@ -77,13 +81,17 @@
<div class="checkout-card"> <div class="checkout-card">
<div class="checkout-head"> <div class="checkout-head">
<h2>Checkout — {checkoutTitle}</h2> <h2>Checkout — {checkoutTitle}</h2>
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button> <Button variant="ghost" size="sm" onclick={resetCheckout}
>{@html chevronLeftIcon} Back to plans</Button
>
</div> </div>
<div bind:this={checkoutEl} class="checkout-host"></div> <div bind:this={checkoutEl} class="checkout-host"></div>
<p class="hint"> <p class="hint">
You can close and go to your dashboard any time — access unlocks once payment completes. You can close and go to your dashboard any time — access unlocks once payment completes.
</p> </p>
<a href={data.famSlug ? `/${data.famSlug}` : '/'} class="dash-link">Go to dashboard →</a> <a href={data.famSlug ? `/${data.famSlug}` : '/'} class="dash-link"
>Go to dashboard {@html chevronRightIcon}</a
>
</div> </div>
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.8.1" "version": "1.8.6"
} }
+11 -2
View File
@@ -184,7 +184,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
{ {
name: "settings", name: "settings",
build: (ids) => build: (ids) =>
col("settings", [rel("famId", ids.fams, true), text("webhookUrl"), bool("simulateEow")], { col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], {
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
@@ -229,6 +229,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
select("period", ["schedule", "daily", "weekly", "monthly"]), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true), select("status", ["active", "completed"], true),
bool("isPocketMoney"), bool("isPocketMoney"),
// Standalone/one-off reward progress window. `period` stays empty for
// these (they're measured once, not per-cycle). `completeBy` bounds how
// far progress counts: `unlimited` (cumulative since startDate),
// `week` (end of current week), or `custom` (a specific completeByDate).
// `startDate` is when tracking begins — without it "unlimited" would
// count points earned before the reward existed.
select("completeBy", ["unlimited", "week", "custom"]),
text("startDate"),
text("completeByDate"),
], { ], {
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
@@ -339,7 +348,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
rel("templateId", ids.chore_templates), rel("templateId", ids.chore_templates),
select("frequency", ["daily", "weekly"], true), select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money", "emoji"], true), select("type", ["points", "money", "emoji"], true),
number("value", true), number("value", false),
text("customName"), text("customName"),
text("description"), text("description"),
text("icon"), text("icon"),
+37
View File
@@ -104,6 +104,43 @@ export function periodEnd(period: string, start: string): string {
return start; return start;
} }
// Compute the completion-counting window for a bonus. Recurring period-based
// bonuses keep their per-cycle window (periodStart..periodEnd). Standalone /
// one-off rewards (no `period`) are scoped by `completeBy` + `startDate`:
// - `completeBy === "week"` → current week [weekStart, weekEnd]
// - `completeBy === "custom"` → [startDate, completeByDate]
// - otherwise (`unlimited`/empty) → [startDate, ∞] (cumulative)
// `from`/`to` of "" mean unbounded on that side (legacy configs with no dates
// count everything since records began, i.e. the admin-dashboard behaviour).
export function bonusWindow(
cfg: { period?: string; completeBy?: string; startDate?: string; completeByDate?: string },
payday: number,
tz: string,
): { from: string; to: string } {
if (cfg.period) {
const start = periodStart(cfg.period, payday, tz);
return { from: start, to: periodEnd(cfg.period, start) };
}
const from = cfg.startDate || "";
let to = "";
if (cfg.completeBy === "week") to = periodEnd("weekly", weekStart(payday, tz));
else if (cfg.completeBy === "custom") to = cfg.completeByDate || "";
return { from, to };
}
// True if a completion record's date falls within the window (dates may be
// YYYY-MM-DD or ISO with a time portion).
export function completionInWindow(
c: { date?: string },
win: { from: string; to: string },
): boolean {
const day = (c.date || "").slice(0, 10);
if (!day) return false;
if (win.from && day < win.from) return false;
if (win.to && day > win.to) return false;
return true;
}
// The completion window a chore of a given frequency is "done for" in the // The completion window a chore of a given frequency is "done for" in the
// current period. Weekly chores are done once per week ([weekStart, +7)), // current period. Weekly chores are done once per week ([weekStart, +7)),
// daily chores once per day ([today, +1)). Half-open [from, to). // daily chores once per day ([today, +1)). Half-open [from, to).