Compare commits

...

43 Commits

Author SHA1 Message Date
JCEEE 7de3c8ca62 propose new chore update fix 2026-09-30 08:12:30 +01:00
JCEEE d14a154dcb fix auth again 2026-09-28 21:13:37 +01:00
JCEEE 89ab955efc add debugging 2026-09-27 11:04:37 +01:00
JCEEE edc8568cc4 update schema 2026-09-25 08:24:26 +01:00
JCEEE c070cc8053 1.11.7 2026-09-24 12:50:26 +01:00
JCEEE 8388868bee enable kids todos 2026-09-24 12:50:20 +01:00
JCEEE 2a3f3836c0 1.11.6 2026-09-23 20:49:36 +01:00
JCEEE 6288a3d3f2 fix week in week out evaluateFam 2026-09-23 20:49:32 +01:00
JCEEE adcb400f41 1.11.5 2026-09-23 09:43:30 +01:00
JCEEE a710e22f35 add yesterday catchup 2026-09-23 09:43:25 +01:00
JCEEE 31da825e93 fix login 2026-09-22 20:39:35 +01:00
JCEEE fa1bf1e09d compact member card 2026-09-22 20:35:50 +01:00
JCEEE 66ed9750e9 fix broken chore gone 2026-09-22 20:10:42 +01:00
JCEEE 0fed19461e update stats 2026-09-21 11:09:09 +01:00
JCEEE 67b5eff06b fix payday 2026-09-21 08:54:37 +01:00
JCEEE a8c674f8b3 use custom emoji for payday 2026-09-21 08:35:13 +01:00
JCEEE 8e4eef06f5 add payday anim 2026-09-20 17:58:38 +01:00
JCEEE 1609e11c48 add new save button 2026-09-20 17:52:53 +01:00
JCEEE 18511b6546 payday tests 2026-09-20 17:49:14 +01:00
JCEEE 9e6d835061 1.11.4 2026-09-20 17:42:58 +01:00
JCEEE 0613fc4f46 payday tests 2026-09-20 17:42:51 +01:00
JCEEE 6b6d2df945 1.11.3 2026-09-20 16:40:12 +01:00
JCEEE 6d05721629 payday tests 2026-09-20 16:40:09 +01:00
JCEEE 3a6ba556e6 fix demo fam bug setting 2026-09-16 22:07:51 +01:00
JCEEE 9566506d30 fix auth issues 2026-09-16 21:07:21 +01:00
JCEEE 812f6a4f88 add showboaters 2026-09-15 15:23:44 +01:00
JCEEE 234b2cd8ee 1.11.2 2026-09-15 15:14:25 +01:00
JCEEE 78f456bf77 some more improvements 2026-09-15 15:14:18 +01:00
JCEEE 833a34049e fix minor dash 2026-09-15 14:11:04 +01:00
JCEEE 39e432e52a 1.11.1 2026-09-15 09:05:25 +01:00
JCEEE 29d50985df fix dem bugs 2026-09-15 09:05:13 +01:00
JCEEE f9fa99f5ff fix bugs 2026-09-14 21:29:10 +01:00
JCEEE 49658bd791 1.11.0 2026-09-14 15:37:05 +01:00
JCEEE b1dd359f5d fix mcs 2026-09-14 15:36:55 +01:00
JCEEE e0dd4c0721 add shared computer feature 2026-09-14 15:33:07 +01:00
JCEEE 6aab203bbe fix some connectivity issue 2026-09-14 13:49:14 +01:00
JCEEE de61a6368b 1.10.8 2026-09-14 13:38:56 +01:00
JCEEE 8bb120e8eb improve quality of life ux 2026-09-14 13:38:52 +01:00
JCEEE da6a6e372c fix bonus completeions 2026-09-14 13:00:13 +01:00
JCEEE e441c3d2a7 1.10.7 2026-09-14 12:12:11 +01:00
JCEEE 158a60f30c add toggle fix and possible log out issue 2026-09-14 12:09:44 +01:00
JCEEE 7003609afe add shared login pin mechansim 2026-09-12 08:45:13 +01:00
JCEEE 70a38ee95b update homepage text 2026-09-11 18:20:18 +01:00
69 changed files with 4503 additions and 944 deletions
+12 -10
View File
@@ -19,14 +19,14 @@
## Auth ## Auth
| Role | Auth | Session | Record in | | Role | Auth | Session | Record in |
| -------------- | --------------------------------------------- | -------------------------- | ------------------------- | | -------------- | ----------------------------------------------- | ------------------------------------------------ | ----------------------- |
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) | | Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
| Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) | | Member (child) | Invite OTP + server-derived password | Shared-device: `pb_token_<userId>` + `pb_active` | `users` (role `child`) |
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — | | Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`. - **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**. - **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. **Shared-computer sessions:** children keep ONE httpOnly cookie per account (`pb_token_<userId>`, set at join) + a `pb_active` cookie naming the current session; a 3-digit PIN _selects_ among those device sessions (see `shared-device.md`) — it is NOT a login and mints nothing on a fresh device. Parents stay on the single `pb_token`. Resolution order in hooks: `pb_active` → `pb_token`. There is **no `members` collection**.
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role. - **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. - The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`). - Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
@@ -35,6 +35,7 @@
- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`) - `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`)
- `pins` — famId, userId, pin (superuser-only shared-device PINs, plaintext so parents can read them out; all access via server endpoints)
- `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes) - `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes)
- `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code"). - `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code").
- `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt - `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt
@@ -46,7 +47,7 @@
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. > **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp`/`pins` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensurePins`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
## Routes ## Routes
@@ -56,6 +57,7 @@
/login · /logout Parent email/password login / logout /login · /logout Parent email/password login / logout
/signup Parent + family signup (wizard: fam → child → code → plan) /signup Parent + family signup (wizard: fam → child → code → plan)
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code= /{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
/{fam}/switch Shared-device profile picker (standalone landing when child sessions exist but none active)
/{fam} Fam dashboard /{fam} Fam dashboard
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session) /{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
/{fam}/{username}/chores Chore templates & assignment grid /{fam}/{username}/chores Chore templates & assignment grid
@@ -157,10 +159,10 @@ let configs = $derived(
Two patterns based on who's acting: Two patterns based on who's acting:
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | | Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- | | ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ---------------------------------- |
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` | | Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie | | Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie |
**Member direct fetch** — optimistic UI via local state mutation, reconciled on response: **Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
+27 -5
View File
@@ -20,6 +20,7 @@
## UI Component Architecture (Jul 2026) ## UI Component Architecture (Jul 2026)
### Layout Hierarchy ### Layout Hierarchy
``` ```
+layout.svelte ← global styles, meta, favicon +layout.svelte ← global styles, meta, favicon
├── /login, /signup, /join/* ← auth pages (no shell) ├── /login, /signup, /join/* ← auth pages (no shell)
@@ -34,6 +35,7 @@
``` ```
### Sidebar (collapsible to mini-mode) ### Sidebar (collapsible to mini-mode)
- Header: app name (FamDone) - Header: app name (FamDone)
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses - Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
- Member CTAs: Dashboard, Preferences - Member CTAs: Dashboard, Preferences
@@ -41,10 +43,12 @@
- Role-aware: items differ based on admin vs member route - Role-aware: items differ based on admin vs member route
### TopNav ### TopNav
- Slot `announcement` (center) — system/family messages - Slot `announcement` (center) — system/family messages
- Slot `actions` (right) — user status, claim/message - Slot `actions` (right) — user status, claim/message
### Page Content ### Page Content
- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort) - `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort)
- `CardGrid` — 3-column grid, Cards span columns via `cols` prop - `CardGrid` — 3-column grid, Cards span columns via `cols` prop
- `Card` — 1/2/3 col span, micro-layout per page - `Card` — 1/2/3 col span, micro-layout per page
@@ -52,6 +56,7 @@
- `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg) - `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg)
### Components (frontend/src/lib/components/) ### Components (frontend/src/lib/components/)
- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte` - `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte`
- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte` - `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte`
- `Button.svelte`, `Accordion.svelte` - `Button.svelte`, `Accordion.svelte`
@@ -177,14 +182,12 @@
- **Admin dashboard stat tiles**: added 4 gradient tiles (members / points / cash / chores done) reusing the child `.tiles`/`.tile` pattern + new `.tile-members`/`.tile-chores` colors, from a new `adminTiles` derived summing `summary.summaries`. Also fixed admin subtitle `Week of {YYYY-MM-DD}` → `Week of {DDMMYY}`. - **Admin dashboard stat tiles**: added 4 gradient tiles (members / points / cash / chores done) reusing the child `.tiles`/`.tile` pattern + new `.tile-members`/`.tile-chores` colors, from a new `adminTiles` derived summing `summary.summaries`. Also fixed admin subtitle `Week of {YYYY-MM-DD}` → `Week of {DDMMYY}`.
- **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up). - **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up).
### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only ### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only
- **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts. - **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts.
- **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset). - **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset).
- **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`. - **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`.
- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts` used `import.meta.env.SERVER_IP` → undefined.) - **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts`used`import.meta.env.SERVER_IP` → undefined.)
- **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code. - **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code.
- **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks). - **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks).
- **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`. - **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`.
@@ -198,12 +201,14 @@
- **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both. - **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both.
### 2026-08-06 — Added root `shared/` for cross-package code ### 2026-08-06 — Added root `shared/` for cross-package code
- Created `shared/timezone.ts` (moved from root `timezone.ts`). Imported by `frontend/src/routes/[fam]/[username]/+page.svelte`, `.../settings/+page.svelte`, and `proxy/src/index.ts`. Deleted the root `timezone.ts`. - Created `shared/timezone.ts` (moved from root `timezone.ts`). Imported by `frontend/src/routes/[fam]/[username]/+page.svelte`, `.../settings/+page.svelte`, and `proxy/src/index.ts`. Deleted the root `timezone.ts`.
- Created `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders (`SCHEMA_PLAN` ordered collection plan + `text/select/rel/...` helpers). Both `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts` now iterate `SCHEMA_PLAN`; kills the previous duplicated schema/field-helper definitions in both files. - Created `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders (`SCHEMA_PLAN` ordered collection plan + `text/select/rel/...` helpers). Both `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts` now iterate `SCHEMA_PLAN`; kills the previous duplicated schema/field-helper definitions in both files.
- Reason: `timezone.ts` and the PB schema are consumed by more than one package; `shared/` is the root location both can reach. Rule added to RULES.md: shared code lives in `shared/`, never inside `frontend/` or `proxy/`. - Reason: `timezone.ts` and the PB schema are consumed by more than one package; `shared/` is the root location both can reach. Rule added to RULES.md: shared code lives in `shared/`, never inside `frontend/` or `proxy/`.
- Note: proxy `tsc --noEmit` already errors on `.ts`-extension imports (`allowImportingTsExtensions` unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the `shared/` imports correctly. Verified `pnpm build` clean for both packages. - Note: proxy `tsc --noEmit` already errors on `.ts`-extension imports (`allowImportingTsExtensions` unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the `shared/` imports correctly. Verified `pnpm build` clean for both packages.
### 2026-08-07 — `@shared/*` import alias (path alias, not a pnpm package) ### 2026-08-07 — `@shared/*` import alias (path alias, not a pnpm package)
- Moved `config.ts` → `shared/config.ts`. All `shared/` code is now imported as `@shared/*` instead of relative `../../shared/...`. - Moved `config.ts` → `shared/config.ts`. All `shared/` code is now imported as `@shared/*` instead of relative `../../shared/...`.
- This is a **path alias**, not a pnpm workspace package (`@shared` alone isn't a valid npm package name; a real package would need `@scope/name`). - This is a **path alias**, not a pnpm workspace package (`@shared` alone isn't a valid npm package name; a real package would need `@scope/name`).
- Proxy: `tsconfig.json` sets `paths: { "@shared/*": ["../shared/*"] }`; esbuild build adds `--alias:@shared=../shared`; tsx resolves via tsconfig paths. Proxy keeps `.ts` extensions (`@shared/config.ts`). - Proxy: `tsconfig.json` sets `paths: { "@shared/*": ["../shared/*"] }`; esbuild build adds `--alias:@shared=../shared`; tsx resolves via tsconfig paths. Proxy keeps `.ts` extensions (`@shared/config.ts`).
@@ -212,6 +217,7 @@
- Docker note: runtime image only copies `frontend/build` + `proxy/dist` (both already bundle `shared/`), so `shared/` needn't be copied into the image. - Docker note: runtime image only copies `frontend/build` + `proxy/dist` (both already bundle `shared/`), so `shared/` needn't be copied into the image.
### 2026-08-10 — Dev runtime cleanup (PB instances / containers) ### 2026-08-10 — Dev runtime cleanup (PB instances / containers)
- **Removed** test container `31e74178b3f0` (`famdone-service-app-1`, host :3010 + :8092). It ran **PB 0.39.10** and bound the **same host `pb_data`** as pb-dev → two PBs (v0.25 + v0.39) writing one SQLite DB = corruption/lock risk (likely source of dev instability/login lag). - **Removed** test container `31e74178b3f0` (`famdone-service-app-1`, host :3010 + :8092). It ran **PB 0.39.10** and bound the **same host `pb_data`** as pb-dev → two PBs (v0.25 + v0.39) writing one SQLite DB = corruption/lock risk (likely source of dev instability/login lag).
- **Killed** 7 stale host `tsx watch` dev-proxy processes (Jul 28–Aug 5) + my throwaway 0.39 PBs. - **Killed** 7 stale host `tsx watch` dev-proxy processes (Jul 28–Aug 5) + my throwaway 0.39 PBs.
- **Reset pb_data**: stopped pb-dev, wiped `/home/threejjjs/development/famchamp/pb_data`, **recreated** pb-dev container (fresh v0.25 store) with `--automigrate=false`, recreated dev superuser `debug@famchamp.dev`/`debug123`. - **Reset pb_data**: stopped pb-dev, wiped `/home/threejjjs/development/famchamp/pb_data`, **recreated** pb-dev container (fresh v0.25 store) with `--automigrate=false`, recreated dev superuser `debug@famchamp.dev`/`debug123`.
@@ -219,6 +225,7 @@
- **Desired end state (confirmed)**: `8090` = pb-dev (v0.25, single instance, automigrate off); `3001`+`8091` = PROD app `cffd636cc772` (kept, not live); PROD pb_data at `/data/coolify/.../pb_data` (separate from dev). - **Desired end state (confirmed)**: `8090` = pb-dev (v0.25, single instance, automigrate off); `3001`+`8091` = PROD app `cffd636cc772` (kept, not live); PROD pb_data at `/data/coolify/.../pb_data` (separate from dev).
### 2026-08-10 — PB 0.25 → 0.39 migration (branch `feature/migrate-pocketbase`) ### 2026-08-10 — PB 0.25 → 0.39 migration (branch `feature/migrate-pocketbase`)
- **Decision**: keep our own `migrate.ts` schema-as-code (API-driven, does data migrations + rule locking), NOT PocketBase's built-in automigrate (schema-only, generates version-specific migration files, and generates conflicting snapshots on upgraded stores). Disable PB automigrate in the runtime. - **Decision**: keep our own `migrate.ts` schema-as-code (API-driven, does data migrations + rule locking), NOT PocketBase's built-in automigrate (schema-only, generates version-specific migration files, and generates conflicting snapshots on upgraded stores). Disable PB automigrate in the runtime.
- **Verified against 0.39.10** (throwaway binaries on `127.0.0.1:8098/8099`, temp data dirs): - **Verified against 0.39.10** (throwaway binaries on `127.0.0.1:8098/8099`, temp data dirs):
1. Fresh store: `migrate()` bootstraps all 14 `SCHEMA_PLAN` collections + every field migration cleanly (schema field builders are 0.39-compatible). 1. Fresh store: `migrate()` bootstraps all 14 `SCHEMA_PLAN` collections + every field migration cleanly (schema field builders are 0.39-compatible).
@@ -228,7 +235,7 @@
- `docker/Dockerfile` `POCKETBASE_VERSION` → `0.39.10`. - `docker/Dockerfile` `POCKETBASE_VERSION` → `0.39.10`.
- `docker/Dockerfile.dev` → `0.39.10` + `CMD ... --automigrate=false`. - `docker/Dockerfile.dev` → `0.39.10` + `CMD ... --automigrate=false`.
- `docker/entrypoint.sh` → `pocketbase serve ... --automigrate=false`. - `docker/entrypoint.sh` → `pocketbase serve ... --automigrate=false`.
- `proxy/src/env.ts` → added non-breaking `PB_ENDPOINT` env override (used to point migrate at a throwaway PB on another port; default dev/prod split unchanged). - `proxy/src/env.ts` → added non-breaking `PB_ENDPOINT` env override (used to point migrate at a throwaway PB on another port; default dev/prod split unchanged).
- **⚠️ 0.39 schema breaking change**: PB 0.39 does **NOT** auto-add `createdAt`/`updatedAt` to **API-created** collections (0.25 did). The chat store filters/sorts on a custom `messages.createdAt`, so a fresh 0.39 store is missing it → raw PB 400. Fixed two ways: - **⚠️ 0.39 schema breaking change**: PB 0.39 does **NOT** auto-add `createdAt`/`updatedAt` to **API-created** collections (0.25 did). The chat store filters/sorts on a custom `messages.createdAt`, so a fresh 0.39 store is missing it → raw PB 400. Fixed two ways:
- `shared/pb/schema.ts`: `messages` now declares `date("createdAt")` explicitly (source of truth → `ensureSchema`). - `shared/pb/schema.ts`: `messages` now declares `date("createdAt")` explicitly (source of truth → `ensureSchema`).
- `proxy/src/migrate.ts`: the "messages already exists" branch now adds `createdAt` if missing (idempotent hardening for drifted/upgraded stores). - `proxy/src/migrate.ts`: the "messages already exists" branch now adds `createdAt` if missing (idempotent hardening for drifted/upgraded stores).
@@ -236,6 +243,7 @@
- **Prod upgrade steps**: backup `pb_data` → run the 0.39 image (automigrate off) → run `migrate()` → verify no drift (`messages.createdAt`, `id.autogeneratePattern`). - **Prod upgrade steps**: backup `pb_data` → run the 0.39 image (automigrate off) → run `migrate()` → verify no drift (`messages.createdAt`, `id.autogeneratePattern`).
### 2026-08-10 — Revert PB to 0.25.8 (backtrack from 0.39) ### 2026-08-10 — Revert PB to 0.25.8 (backtrack from 0.39)
- **Decision**: backtrack off the PocketBase 0.39 bump (introduced in commit `3725c54` via `ARG POCKETBASE_VERSION=0.39.10`) and work from a **0.25.8 baseline** in BOTH dev and prod, then migrate to 0.39 deliberately later. - **Decision**: backtrack off the PocketBase 0.39 bump (introduced in commit `3725c54` via `ARG POCKETBASE_VERSION=0.39.10`) and work from a **0.25.8 baseline** in BOTH dev and prod, then migrate to 0.39 deliberately later.
- Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`. - Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`.
- **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create. - **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create.
@@ -332,7 +340,7 @@
### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented ### 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'`. - **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). - **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`: - **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 - `unlimited` (default) → `[startDate, ∞]` cumulative
@@ -367,3 +375,17 @@
- **Feature:** Count-type rewards now support pinning to a single assigned chore (`bonus_configs.targetChoreId`). On the fam-admin Rewards modal (Step 2), when Type = "Count - (chores)" a **Target Chore** dropdown appears after the Target Value field — options are the selected member's assigned (non-todo) chores, default "All Chores". Evaluation (`bonuses.evaluateFam`) + display (`bonuses.progress`, dashboard `thresholdGoals`) now filter Count progress to only that chore's completions when set. Platform-level template form only needed the relabeled "Count - (chores)" — no target chore at template level. Pocket-money checkbox removed from the platform template form (only the auto-created per-child droplet needs it). - **Feature:** Count-type rewards now support pinning to a single assigned chore (`bonus_configs.targetChoreId`). On the fam-admin Rewards modal (Step 2), when Type = "Count - (chores)" a **Target Chore** dropdown appears after the Target Value field — options are the selected member's assigned (non-todo) chores, default "All Chores". Evaluation (`bonuses.evaluateFam`) + display (`bonuses.progress`, dashboard `thresholdGoals`) now filter Count progress to only that chore's completions when set. Platform-level template form only needed the relabeled "Count - (chores)" — no target chore at template level. Pocket-money checkbox removed from the platform template form (only the auto-created per-child droplet needs it).
- **TODO (logic, not yet fixed):** Count rewards with a `period` (e.g. weekly) reset + re-earn each period like a points/cash threshold. Once a Count reward is pinned to a target (or all chores), the intended semantics are ambiguous: should it be **continuous** (unlimited, measured once since startDate until reached) or **limited to the period window** (re-earn each week)? Currently it follows the periodic-reset behavior. Decide whether Count rewards should ignore `period` (behave as standalone/unlimited since startDate) and adjust `bonusWindow`/evaluation accordingly. Not attempted in this change. - **TODO (logic, not yet fixed):** Count rewards with a `period` (e.g. weekly) reset + re-earn each period like a points/cash threshold. Once a Count reward is pinned to a target (or all chores), the intended semantics are ambiguous: should it be **continuous** (unlimited, measured once since startDate until reached) or **limited to the period window** (re-earn each week)? Currently it follows the periodic-reset behavior. Decide whether Count rewards should ignore `period` (behave as standalone/unlimited since startDate) and adjust `bonusWindow`/evaluation accordingly. Not attempted in this change.
### 2026-09-11 — Shared-device PIN switching (a computer shared by siblings)
- **Problem:** a single `pb_token` cookie meant joining kid B on the family computer silently logged out kid A; every hand-off needed a fresh parent-issued OTP. Design note: `shared-device.md` (decisions + flows).
- **Model — the PIN is NOT a login:** it _selects_ among sessions that already exist on the device (`pb_token_<childId>` per child + `pb_active` naming the current one). PIN alone mints nothing on a fresh device; a stolen cookie alone selects nothing. Threat model = sibling mischief; devtools-level bypass explicitly accepted (data is family-scoped, toggles reversible).
- **Sessions (`session.ts` + `hooks.server.ts`):** children store one httpOnly cookie per account (`pb_token_<id>`, 5d TTL) + `pb_active`. Resolution order: `pb_active` first, then legacy `pb_token` (parents + pre-feature children). Parent login/join clears `pb_active` → supersedes kid mode; kid switches shadow (don't delete) a parent session; `/logout` clears everything. Per-profile removal = picker ✕ → `POST /api/device/remove` (device-local cookie surgery, no session required).
- **`pins` collection** (superuser-only rules like `otp`): plaintext 3-digit PIN, deliberately parent-recoverable (Q1: View). All access server-side with session-role checks — kids can never read siblings' pins via PB rules. Created on boot via `ensurePins({})` in the always-run migrate path (existing installs — no DB wipe), plus `settings.lockMins` via `SCHEMA_PLAN` + `ensureSettingsFields`.
- **Endpoints:** `POST /api/switch-user {userId, pin}` — cookie-presence gate, superuser PIN verify, in-memory rate limit (5 fails → 30s), **self-healing**: expired device tokens re-mint via the derived password (server-only) instead of forcing a re-join. `POST /api/pins` — `set` (first-time), `change` (needs current), `ensure` (set-if-missing, used by the join wizard so joining a _second_ shared device doesn't clash with an existing PIN).
- **Picker (`SharedPicker` component)** — one UI everywhere: top-nav colour-dot (any session), idle-lock overlay, and standalone `/{fam}/switch` landing (fam layout redirects there when kid cookies exist but none is active; join pages excluded from the redirect; `+page.server.ts` sends active sessions home). PIN required on every pick incl. "resume" (deliberate-select property). Standalone footer links: join with a code + parent login.
- **Join wizard (`JoinPinFlow`):** after OTP redeem → "shared with siblings?" → PIN setup (skipped on "no"), **forced when the device already has other kid sessions**; then straight to the kid's dashboard. All three child join routes updated (root `/join`, `/{fam}/join`, `/{fam}/join/{username}`); parents keep the single-session flow.
- **Idle lock (client, `lib/client/lock.ts`):** `localStorage[fam_last_active]` written on click/key/touch (throttled 10s) + force-written on `pagehide` → survives browser close/sleep/restart (next launch compares elapsed; live 15s interval mid-session). Default **10 min** (user-set: default 10, not 2-3); parent-adjustable `Off / 2 / 10` in Settings → Family → **Shared computer**. Children only; tab-switches don't lock; two tabs share the timestamp so the active tab arbitrates.
- **Parent/child PIN UI:** Settings → Invites → All members → **PIN** per member (reveal + give-a-new-PIN modal); child Preferences → **My PIN** (set, or change with current PIN; "forgot? parent can read it out").
- **Typecheck/build:** `svelte-check` still 4 pre-existing canary errors only (chores RewardType/Frequency ×3, qrcode decl — files untouched by this change); prettier run over touched files. Migration applies on next dev-server boot (migrateOnBoot) — no DB wipe; hooks changes need the dev server restart (auto).
- **Caveats:** legacy single-cookie child sessions work but don't appear in the picker until re-join; multi-tab concurrency accepted (single-tab-norm on family desktops); `shared-device.md` records the settled open questions (view-not-reset, optional-at-join, no parent switcher, straight-to-dashboard, no cross-family).
+75 -22
View File
@@ -2,8 +2,13 @@ import type { Handle } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient } from '$lib/server/pocketbase';
import { import {
SESSION_COOKIE, SESSION_COOKIE,
ACTIVE_COOKIE,
childSessionCookie,
setSessionCookie, setSessionCookie,
clearSessionCookie, clearSessionCookie,
setChildSessionCookie,
setActiveChild,
clearActiveChild,
PLATFORM_SESSION_COOKIE, PLATFORM_SESSION_COOKIE,
clearPlatformSession clearPlatformSession
} from '$lib/server/session'; } from '$lib/server/session';
@@ -14,6 +19,22 @@ import { migrateOnBoot } from '$lib/server/migrate-boot';
// Run the PB schema migration once at server boot (idempotent). // Run the PB schema migration once at server boot (idempotent).
void migrateOnBoot(); void migrateOnBoot();
function sessionFrom(record: any, freshToken: string) {
return {
id: record.id,
name: record.name || record.username || '',
username: handleOf(record.username || ''),
role: record.role || 'parent',
famId: record.famId,
color: record.color || '',
pattern: record.pattern || '',
themeSize: record.themeSize || '',
themeOpacity: record.themeOpacity || '',
partyEmoji: record.partyEmoji || '',
token: freshToken
} satisfies SessionUser & { token: string };
}
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null; event.locals.user = null;
event.locals.pbToken = null; event.locals.pbToken = null;
@@ -36,37 +57,69 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event); return resolve(event);
} }
// Fam-user session: pb_token JWT → authRefresh → locals.user. // Shared-device sessions: `pb_active` names the child whose cookie is the
// current session. Resolved FIRST so a kid switch on a family computer
// supersedes any shadowed single pb_token (parent) session.
const activeId = event.cookies.get(ACTIVE_COOKIE);
if (activeId) {
const childToken = event.cookies.get(childSessionCookie(activeId));
if (!childToken) {
// Stale active pointer (cookie removed) — clean it up and fall through.
clearActiveChild(event.cookies);
} else {
try {
const pb = createPbClient(childToken);
// authRefresh() does two jobs in one call:
// 1. Verifies the token (PB JWTs can't be checked offline — the
// signing secret is per-record and never leaves PB).
// 2. Returns the current record — the only way to get
// name/role/famId, since PB doesn't embed custom fields.
const { record, token: freshToken } = await pb.collection('users').authRefresh();
if (record.role === 'child') {
const session = sessionFrom(record, freshToken);
event.locals.user = session;
event.locals.pbToken = session.token;
if (freshToken !== childToken) {
setChildSessionCookie(event.cookies, activeId, freshToken);
}
// Sliding expiry: pb_active has the same 5-day maxAge as the
// token cookies but was previously never re-set, so it aged
// out 5 days after join/switch despite daily use. Re-set it
// on every authenticated request to keep it alive.
setActiveChild(event.cookies, activeId);
return resolve(event);
}
console.error(
`[diag] hooks child wrong-role activeId=${activeId} role=${record.role} path=${event.url.pathname}`
);
} catch (e) {
// Expired/revoked/malformed — leave the cookie; the picker switch
// re-mints it server-side. Fall through to the single session.
console.error(
`[diag] hooks child authRefresh-fail activeId=${activeId} path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
);
}
}
}
// Single session: pb_token JWT (parents, or children from before the
// multi-session scheme) → 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 {
// authRefresh() does two jobs in one call:
// 1. Verifies the token (PB JWTs can't be checked offline — the
// signing secret is per-record and never leaves PB), so this
// round trip IS the verification step.
// 2. Returns the current record — the only way to get
// name/role/famId, since PB doesn't embed custom fields in the
// token itself.
const { record, token: freshToken } = await pb.collection('users').authRefresh(); const { record, token: freshToken } = await pb.collection('users').authRefresh();
event.locals.user = { const session = sessionFrom(record, freshToken);
id: record.id, event.locals.user = session;
name: record.name || record.username || '', event.locals.pbToken = session.token;
username: handleOf(record.username || ''),
role: record.role || 'parent',
famId: record.famId,
color: record.color || '',
pattern: record.pattern || '',
themeSize: record.themeSize || '',
themeOpacity: record.themeOpacity || ''
} satisfies SessionUser;
event.locals.pbToken = freshToken;
if (freshToken !== token) { if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken); setSessionCookie(event.cookies, freshToken);
} }
} catch { } catch (e) {
// Expired, malformed, or revoked — drop it and treat as logged out. // Expired, malformed, or revoked — drop it and treat as logged out.
console.error(
`[diag] hooks single authRefresh-fail path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
);
clearSessionCookie(event.cookies); clearSessionCookie(event.cookies);
} }
} }
+5 -2
View File
@@ -20,8 +20,11 @@ async function memberFetch<T = unknown>(
} }
export const memberApi = { export const memberApi = {
async toggleCompletion(famId: string, assignedChoreId: string, date: string) { async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date }); return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
},
async createTodo(name: string) {
return memberFetch<{ record: unknown }>('POST', '/api/todos', { name });
}, },
async claimReward(famId: string, rewardId: string) { async claimReward(famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`); return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
+53
View File
@@ -0,0 +1,53 @@
// Shared-device idle lock (client-side). Tracks the last interaction
// timestamp in localStorage and exposes "should the app return to the profile
// picker?" checks. localStorage (not cookies/beacons) so the timestamp
// survives browser close, sleeps and restarts — the pagehide write is
// synchronous and can't be lost on tab close.
const KEY = 'fam_last_active';
const WRITE_THROTTLE_MS = 10_000;
let installed = false;
let lastWrite = 0;
export function recordActivity(force = false) {
if (typeof localStorage === 'undefined') return;
const now = Date.now();
if (!force && now - lastWrite < WRITE_THROTTLE_MS) return;
try {
localStorage.setItem(KEY, String(now));
lastWrite = now;
} catch {
/* private mode etc. — the lock simply has no data yet */
}
}
export function lastActiveAt(): number {
if (typeof localStorage === 'undefined') return 0;
const raw = localStorage.getItem(KEY);
const n = raw ? Number(raw) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
export function lockDue(lockMins: number): boolean {
if (!lockMins || lockMins <= 0) return false;
const last = lastActiveAt();
if (!last) return false;
return Date.now() - last > lockMins * 60_000;
}
export function installLockTracking() {
if (installed || typeof document === 'undefined') return;
installed = true;
const on = () => recordActivity();
document.addEventListener('click', on);
document.addEventListener('keydown', on);
document.addEventListener('touchstart', on);
window.addEventListener('pagehide', () => recordActivity(true));
}
// Fresh activation (join wizard, picker switch) — write now so the just-opened
// session doesn't instantly trip the mount-time lock check.
export function resetClock() {
recordActivity(true);
}
+32
View File
@@ -0,0 +1,32 @@
// Per-device "this is a shared computer" flag. localStorage is the source of
// truth (shared-ness is a property of THIS browser, not the family — a DB
// flag would force PIN mode on every device including a parent's phone).
// A plain cookie mirror lets server loads see it too (localStorage never
// reaches the server). Neither is a security boundary: the PIN + device
// session cookies remain the actual gate (see shared-device.md).
const LS_KEY = 'fam_shared_device';
const COOKIE = 'fam_shared_device';
export function isSharedDevice(): boolean {
if (typeof localStorage === 'undefined') return false;
try {
return localStorage.getItem(LS_KEY) === '1';
} catch {
return false;
}
}
export function setSharedDevice(on: boolean) {
try {
if (on) localStorage.setItem(LS_KEY, '1');
else localStorage.removeItem(LS_KEY);
} catch {
/* private mode etc. — flag simply doesn't persist */
}
if (typeof document !== 'undefined') {
document.cookie =
on
? `${COOKIE}=1; path=/; max-age=31536000; SameSite=Lax`
: `${COOKIE}=; path=/; max-age=0; SameSite=Lax`;
}
}
+1 -1
View File
@@ -208,7 +208,7 @@
<span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span> <span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span>
{/if} {/if}
<div class="bubble" style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}"> <div class="bubble" style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}">
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)} {#each renderContent(msg.content) as seg, i (msg.id + ':' + i)}
{#if seg.mention} {#if seg.mention}
<button class="mention" onclick={() => insertMention(seg.text.slice(1))} <button class="mention" onclick={() => insertMention(seg.text.slice(1))}
>{seg.text}</button >{seg.text}</button
@@ -0,0 +1,157 @@
<script lang="ts">
import PinPad from './PinPad.svelte';
import { resetClock } from '$lib/client/lock';
// Post-join wizard for children on a shared device:
// 1. "Is this computer shared?" (skipped when the device already has
// other child sessions — evidence of sharing, PIN is required).
// 2. Pick a 3-digit PIN (optional on the question path).
// 3. Navigate to the child's dashboard.
let {
targetUrl = '',
force = false
}: {
targetUrl: string;
force?: boolean;
} = $props();
// force: the device already had other kids' sessions → PIN required now.
let step = $state<'q' | 'p1' | 'p2'>(force ? 'p1' : 'q');
let pin1 = $state('');
let padKey = $state(0);
let status = $state('');
function go() {
resetClock();
window.location.assign(targetUrl);
}
async function savePin(pin: string) {
status = '';
try {
const res = await fetch('/api/pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'ensure', pin })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
go();
} catch (e) {
status = e instanceof Error ? e.message : 'Could not save PIN';
step = 'p1';
padKey += 1;
}
}
</script>
{#if step === 'q'}
<div class="wizard">
<h2 class="wizard-title">Is this computer shared?</h2>
<p class="wizard-sub">
Will your brothers or sisters use this computer too? A 3-digit PIN lets you switch to your
chores in a tap.
</p>
<div class="wizard-actions">
<button type="button" class="wizard-btn primary" onclick={() => (step = 'p1')}>Yes</button>
<button type="button" class="wizard-btn" onclick={go}>No — just me</button>
</div>
</div>
{:else if step === 'p1'}
<div class="wizard">
<h2 class="wizard-title">Pick a PIN</h2>
<p class="wizard-sub">3 numbers that are easy for you to remember — you'll use it to switch.</p>
{#key padKey}
<PinPad
label="New PIN"
oncomplete={(pin) => {
pin1 = pin;
step = 'p2';
}}
/>
{/key}
{#if status}<p class="wizard-error">{status}</p>{/if}
<button type="button" class="wizard-link" onclick={go}>Skip for now</button>
</div>
{:else if step === 'p2'}
<div class="wizard">
<h2 class="wizard-title">Confirm your PIN</h2>
<p class="wizard-sub">
Enter it once more — {pin1 ? `it starts with ${pin1[0]}·` : ''}your parent can always read it
out if you forget.
</p>
{#key padKey}
<PinPad label="Confirm PIN" oncomplete={savePin} />
{/key}
{#if status}<p class="wizard-error">{status}</p>{/if}
<button
type="button"
class="wizard-link"
onclick={() => {
step = 'p1';
padKey += 1;
}}>Back</button
>
</div>
{/if}
<style>
.wizard {
text-align: center;
}
.wizard-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
color: #0f172a;
}
.wizard-sub {
margin: 0 0 1rem;
font-size: 0.85rem;
color: #64748b;
line-height: 1.5;
}
.wizard-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.wizard-btn {
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 10px;
padding: 0.7rem 1rem;
font-size: 0.95rem;
font-weight: 600;
color: #334155;
cursor: pointer;
}
.wizard-btn.primary {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.wizard-btn:hover {
filter: brightness(0.97);
}
.wizard-link {
margin-top: 0.75rem;
border: none;
background: none;
color: #6366f1;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
padding: 0.4rem 0.75rem;
border-radius: 8px;
}
.wizard-link:hover {
background: #eef2ff;
}
.wizard-error {
margin: 0.75rem 0 0;
font-size: 0.85rem;
font-weight: 600;
color: #dc2626;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
let {
label = 'Enter your PIN',
busy = false,
oncomplete
}: {
label?: string;
busy?: boolean;
oncomplete: (pin: string) => void;
} = $props();
let digits = $state<string[]>([]);
function press(d: string) {
if (busy || digits.length >= 3) return;
digits = [...digits, d];
if (digits.length === 3) oncomplete(digits.join(''));
}
function back() {
digits = digits.slice(0, -1);
}
</script>
<div class="pin-pad">
<p class="pin-label">{label}</p>
<div class="pin-dots" aria-hidden="true">
{#each [0, 1, 2] as i}
<span class="dot" class:filled={digits.length > i}></span>
{/each}
</div>
<div class="pin-keys">
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as d}
<button type="button" class="key" disabled={busy} onclick={() => press(d)}>{d}</button>
{/each}
<button
type="button"
class="key ghost"
disabled={busy || digits.length === 0}
onclick={() => back()}>⌫</button
>
<button type="button" class="key" disabled={busy} onclick={() => press('0')}>0</button>
<span class="key ghost">&nbsp;</span>
</div>
</div>
<style>
.pin-pad {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
}
.pin-label {
font-size: 0.9rem;
color: #6b7280;
margin: 0;
}
.pin-dots {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #cbd5e1;
transition: background 0.1s;
}
.dot.filled {
background: #6366f1;
border-color: #6366f1;
}
.pin-keys {
display: grid;
grid-template-columns: repeat(3, 64px);
gap: 0.5rem;
justify-content: center;
}
.key {
height: 56px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
color: #1e293b;
font-size: 1.4rem;
font-weight: 600;
cursor: pointer;
transition:
background 0.1s,
transform 0.05s;
}
.key:hover:not(:disabled) {
background: #f1f5f9;
}
.key:active:not(:disabled) {
transform: scale(0.96);
}
.key:disabled {
opacity: 0.4;
cursor: default;
}
.key.ghost {
background: transparent;
border-color: transparent;
font-size: 1rem;
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
import Button from './Button.svelte';
let {
label,
successLabel = 'Saved ✓',
variant = 'primary',
size = 'sm',
...rest
}: {
label: string;
successLabel?: string;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
size?: 'sm' | 'md' | 'lg';
[key: string]: unknown;
} = $props();
let saved = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
// Parent forms call this (via bind:this) from their use:enhance callback
// on result.type === 'success'. Green for 4s, then back to normal.
export function flash() {
saved = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => (saved = false), 4000);
}
</script>
<Button type="submit" {size} variant={saved ? 'success' : variant} {...rest}>
{saved ? successLabel : label}
</Button>
@@ -0,0 +1,305 @@
<script lang="ts">
import PinPad from './PinPad.svelte';
import { resetClock } from '$lib/client/lock';
export type QuickProfile = {
id: string;
name: string;
color: string;
username: string; // `{famSlug}:{handle}`
};
let {
profiles = [],
famSlug = '',
famName = '',
activeId = '',
standalone = false,
oncancel = () => {}
}: {
profiles?: QuickProfile[];
famSlug?: string;
famName?: string;
activeId?: string;
standalone?: boolean;
oncancel?: () => void;
} = $props();
let selectedId = $state('');
let padKey = $state(0);
let status = $state('');
let busy = $state(false);
const selected = $derived(profiles.find((p) => p.id === selectedId) || null);
function initial(name: string) {
return (name || '?').trim().charAt(0).toUpperCase() || '?';
}
function targetUrl(p: QuickProfile) {
const handle = (p.username || '').split(':').pop() || p.id;
return `/${famSlug}/${encodeURIComponent(handle)}`;
}
async function switchTo(id: string, pin: string) {
busy = true;
status = '';
try {
const res = await fetch('/api/switch-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: id, pin })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not switch');
// Fresh activation — the mount-time lock check must not trip.
resetClock();
const target = profiles.find((p) => p.id === id);
window.location.assign(targetUrl(target || ({ id, username: '' } as QuickProfile)));
} catch (e) {
status = e instanceof Error ? e.message : 'Could not switch';
busy = false;
padKey += 1;
selectedId = '';
}
}
async function removeProfile(id: string) {
const p = profiles.find((x) => x.id === id);
if (!p || !confirm(`Remove ${p.name} from this computer?`)) return;
await fetch('/api/device/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: id })
}).catch(() => {});
window.location.reload();
}
</script>
<div class="picker-backdrop" class:standalone>
<div class="picker-card">
{#if selected}
<h2 class="picker-title">Hi {selected.name}!</h2>
<p class="picker-sub">Enter your 3-digit PIN to switch.</p>
{#key padKey}
<PinPad {busy} oncomplete={(pin) => switchTo(selected.id, pin)} />
{/key}
{#if status}
<p class="picker-error">{status}</p>
{/if}
<div class="picker-actions">
<button
type="button"
class="link-btn"
onclick={() => {
selectedId = '';
status = '';
padKey += 1;
}}>Back</button
>
</div>
{:else}
<h2 class="picker-title">
{standalone ? famName || 'Welcome' : "Who's using the computer?"}
</h2>
<p class="picker-sub">
{standalone
? 'Pick your profile to get to your chores.'
: 'Pick a profile and enter its PIN.'}
</p>
<div class="picker-grid">
{#each profiles as p (p.id)}
<div
class="profile"
class:active={p.id === activeId}
role="button"
tabindex="0"
style={`--dot:${p.color}`}
onclick={() => (selectedId = p.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') selectedId = p.id;
}}
>
<span class="avatar" style="background:{p.color}">{initial(p.name)}</span>
<span class="pname">{p.name}</span>
{#if p.id === activeId}<span class="pill">current</span>{/if}
<button
type="button"
class="remove-btn"
aria-label={`Remove ${p.name} from this computer`}
onclick={(e) => {
e.stopPropagation();
removeProfile(p.id);
}}>✕</button
>
</div>
{/each}
</div>
{#if standalone}
<div class="picker-footer">
<a href={`/${famSlug}/join`}>Add a child with a code</a>
<span class="dot-sep">·</span>
<a href="/login">Parent login</a>
</div>
{:else}
<div class="picker-actions">
<button type="button" class="link-btn" onclick={oncancel}>Cancel</button>
</div>
{/if}
{/if}
</div>
</div>
<style>
.picker-backdrop {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.picker-card {
background: #fff;
border-radius: 20px;
padding: 2rem;
width: 100%;
max-width: 460px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
text-align: center;
max-height: 90vh;
overflow-y: auto;
}
.picker-title {
margin: 0 0 0.25rem;
font-size: 1.3rem;
color: #0f172a;
}
.picker-sub {
margin: 0 0 1.25rem;
font-size: 0.9rem;
color: #64748b;
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.profile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 1rem 0.5rem;
border: 2px solid transparent;
border-radius: 14px;
background: #f8fafc;
cursor: pointer;
transition:
border-color 0.12s,
transform 0.05s;
}
.profile:hover {
border-color: #c7d2fe;
}
.profile:active {
transform: scale(0.97);
}
.profile.active {
border-color: #6366f1;
background: #eef2ff;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 50%;
color: #fff;
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.pname {
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pill {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
background: #6366f1;
color: #fff;
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.remove-btn {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
background: #fecaca;
color: #b91c1c;
}
.picker-error {
margin: 0.75rem 0 0;
font-size: 0.85rem;
font-weight: 600;
color: #dc2626;
}
.picker-actions {
margin-top: 1rem;
}
.link-btn {
border: none;
background: none;
color: #6366f1;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0.4rem 0.75rem;
border-radius: 8px;
}
.link-btn:hover {
background: #eef2ff;
}
.picker-footer {
display: flex;
justify-content: center;
gap: 0.6rem;
font-size: 0.85rem;
}
.picker-footer a {
color: #6366f1;
font-weight: 600;
text-decoration: none;
}
.picker-footer a:hover {
text-decoration: underline;
}
.dot-sep {
color: #cbd5e1;
}
</style>
+23 -1
View File
@@ -3,11 +3,13 @@
announcement = '', announcement = '',
role = '', role = '',
seasons = [], seasons = [],
children, connected = true,
children
}: { }: {
announcement?: string announcement?: string
role?: string role?: string
seasons?: { id: string; name: string; color: string; active: boolean }[] seasons?: { id: string; name: string; color: string; active: boolean }[]
connected?: boolean
children?: any children?: any
} = $props(); } = $props();
</script> </script>
@@ -17,6 +19,14 @@
{#if role} {#if role}
<span class="role-pill {role}">{role}</span> <span class="role-pill {role}">{role}</span>
{/if} {/if}
<span
class="conn-dot"
class:up={connected}
class:down={!connected}
title={connected ? 'Live — realtime connected' : 'Reconnecting — live updates paused'}
role="status"
aria-label={connected ? 'Realtime connected' : 'Realtime reconnecting'}
></span>
</div> </div>
<div class="topnav-announcement"> <div class="topnav-announcement">
{#if announcement}<span class="announcement-text">{announcement}</span>{/if} {#if announcement}<span class="announcement-text">{announcement}</span>{/if}
@@ -68,6 +78,18 @@
} }
.role-pill.parent { background: #fef3c7; color: #b45309; } .role-pill.parent { background: #fef3c7; color: #b45309; }
.role-pill.child { background: #dbeafe; color: #1d4ed8; } .role-pill.child { background: #dbeafe; color: #1d4ed8; }
.conn-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.conn-dot.up { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); }
.conn-dot.down { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); animation: conn-pulse 1.2s infinite; }
@keyframes conn-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.season-pill { .season-pill {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
+58 -1
View File
@@ -17,7 +17,13 @@
active: string; active: string;
onchange: (v: string) => void; onchange: (v: string) => void;
}; };
weeknav?: { current: string; onPrev: () => void; onNext: () => void }; weeknav?: {
current: string;
onPrev: () => void;
onNext: () => void;
onCurrent?: () => void;
isCurrent?: boolean;
};
sort?: { sort?: {
options: { label: string; value: string }[]; options: { label: string; value: string }[];
active: string; active: string;
@@ -35,6 +41,15 @@
{@render children?.()} {@render children?.()}
{#if weeknav?.onCurrent}
<button
class="now-btn header-now"
class:now-hidden={weeknav.isCurrent !== false}
onclick={weeknav.onCurrent}
tabindex={weeknav.isCurrent === false ? 0 : -1}
><span>← Back to this week</span></button>
{/if}
<div class="view-tools"> <div class="view-tools">
{#if tabs} {#if tabs}
<div class="tab-bar"> <div class="tab-bar">
@@ -142,6 +157,13 @@
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
} }
/* Back-to-this-week CTA: lives outside .view-tools on the header's top
row, pushed right. Always rendered (visibility-toggled) so the header
keeps its height when it appears/disappears. */
.header-now {
margin-left: auto;
align-self: flex-end;
}
.nav-btn { .nav-btn {
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
@@ -154,6 +176,41 @@
.nav-btn:hover { .nav-btn:hover {
background: #f3f4f6; background: #f3f4f6;
} }
.nav-btn.now-btn {
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
}
/* Same pill CTA as the member hero. Always rendered (visibility-toggled)
so the ‹ › buttons never jump when it appears/disappears. */
.now-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.now-btn span {
display: inline-block;
background: rgba(255, 255, 255, 0.92);
color: #4338ca;
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-size: 0.8rem;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.view-header.hero .now-btn span {
background: rgba(255, 255, 255, 0.92);
color: #4338ca;
}
.now-btn:hover span {
background: #fff;
}
.now-btn.now-hidden {
visibility: hidden;
pointer-events: none;
}
.nav-label { .nav-label {
font-size: 0.85rem; font-size: 0.85rem;
color: #374151; color: #374151;
+6
View File
@@ -36,6 +36,8 @@ 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>'; '<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 = 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>'; '<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 monitorIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
export const sendIcon = 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>'; '<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 = export const checkCircleIcon =
@@ -64,3 +66,7 @@ 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>'; '<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 = 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>'; '<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>';
export const shieldIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 11.5 2 2 4-4"/></svg>';
export const laptopIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9"/><path d="M4 16H2.9a1 1 0 0 0-.95 1.33l.6 1.5a1 1 0 0 0 .95.67h17a1 1 0 0 0 .95-.67l.6-1.5A1 1 0 0 0 21.1 16H20"/></svg>';
+4
View File
@@ -6,6 +6,7 @@ export { default as ViewHeader } from './ViewHeader.svelte';
export { default as Card } from './Card.svelte'; export { default as Card } from './Card.svelte';
export { default as CardGrid } from './CardGrid.svelte'; export { default as CardGrid } from './CardGrid.svelte';
export { default as Button } from './Button.svelte'; export { default as Button } from './Button.svelte';
export { default as SaveButton } from './SaveButton.svelte';
export { default as Accordion } from './Accordion.svelte'; export { default as Accordion } from './Accordion.svelte';
export { default as AccordionItem } from './AccordionItem.svelte'; export { default as AccordionItem } from './AccordionItem.svelte';
export { default as Chat } from './Chat.svelte'; export { default as Chat } from './Chat.svelte';
@@ -14,3 +15,6 @@ export { default as NoticeDialog } from './NoticeDialog.svelte';
export { default as PricingPlans } from './PricingPlans.svelte'; export { default as PricingPlans } from './PricingPlans.svelte';
export { default as CheckboxGrid } from './CheckboxGrid.svelte'; export { default as CheckboxGrid } from './CheckboxGrid.svelte';
export { default as PatternPicker } from './PatternPicker.svelte'; export { default as PatternPicker } from './PatternPicker.svelte';
export { default as PinPad } from './PinPad.svelte';
export { default as SharedPicker } from './SharedPicker.svelte';
export { default as JoinPinFlow } from './JoinPinFlow.svelte';
+2 -2
View File
@@ -235,8 +235,8 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp:
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired'); if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
const authPb = createPbClient(); const authPb = createPbClient();
await authPb const { record } = await authPb
.collection('users') .collection('users')
.authWithPassword(fullUsername, derivePassword(famSlug, handleName)); .authWithPassword(fullUsername, derivePassword(famSlug, handleName));
return authPb.authStore.token; return { token: authPb.authStore.token, userId: record.id };
} }
+99 -8
View File
@@ -262,6 +262,54 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
}); });
} }
// Child shared-device PINs. Superuser-only rules (like `otp`) — all access
// goes through server endpoints with session-role checks, so children can
// never read siblings' pins via PB rules.
async function ensurePins(ids: Record<string, string>): Promise<void> {
if (await getCollection('pins')) return;
const famsId = ids.fams || (await getCollection('fams'))?.id;
const usersId = ids.users || (await getCollection('users'))?.id;
if (!famsId || !usersId) throw new Error('fams/users collection not found');
await createCollection({
name: 'pins',
type: 'base',
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: 'famId',
type: 'relation',
required: true,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
},
{
name: 'userId',
type: 'relation',
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: false
},
{ name: 'pin', type: 'text', required: false }
]
});
}
// Idempotent field-add for the shared-device idle lock (0 = off, else mins).
async function ensureSettingsFields(): Promise<void> {
const settingsCol = await getCollection('settings');
if (!settingsCol) return;
const has = (n: string) => settingsCol.fields.some((f: any) => f.name === n);
if (has('lockMins')) return;
settingsCol.fields.push({ name: 'lockMins', type: 'number', required: false });
await updateCollection(settingsCol.id, { fields: settingsCol.fields });
}
// Platform access codes — the codes that enable access to the platform. They're // Platform access codes — the codes that enable access to the platform. They're
// global (not fam-scoped) and managed via the platform admin page (superuser // global (not fam-scoped) and managed via the platform admin page (superuser
// only), so all rules are null like `otp`. A code grants a family a subscription // only), so all rules are null like `otp`. A code grants a family a subscription
@@ -404,6 +452,7 @@ async function ensureSchema(): Promise<void> {
await ensureUsers(ids); await ensureUsers(ids);
await ensureOtp(ids); await ensureOtp(ids);
await ensurePins(ids);
console.log('[migrate] Schema bootstrapped.'); console.log('[migrate] Schema bootstrapped.');
} }
@@ -430,6 +479,9 @@ async function ensureUserFields(): Promise<void> {
if (!has('passwordResetExpiry')) { if (!has('passwordResetExpiry')) {
needed.push({ name: 'passwordResetExpiry', type: 'text', required: false }); needed.push({ name: 'passwordResetExpiry', type: 'text', required: false });
} }
if (!has('partyEmoji')) {
needed.push({ name: 'partyEmoji', type: 'text', required: false });
}
if (needed.length) { if (needed.length) {
await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] }); await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] });
} }
@@ -473,11 +525,16 @@ export async function migrate(): Promise<void> {
// return) so new platform collections/fields/seed land on existing installs. // return) so new platform collections/fields/seed land on existing installs.
await ensureUserFields(); await ensureUserFields();
await ensureFamFields(); await ensureFamFields();
await ensureSettingsFields();
await ensureBonusFields(); await ensureBonusFields();
await ensureTemplateFields(); await ensureTemplateFields();
await ensureAssignedChoreFields(); await ensureAssignedChoreFields();
await ensureAccessCodes(); await ensureFamReadRules(); await ensureAccessCodes();
await ensurePlatform(); await ensurePlatform();
// Superuser-only collections also land on EXISTING installs (ensureSchema's
// early return skips them). Like ensureOtp before it, ensurePins no-ops when
// the collection already exists.
await ensurePins({});
await ensureDefaultSeasons(); await ensureDefaultSeasons();
if (await isDemo()) { if (await isDemo()) {
await seedDemoFamily(); await seedDemoFamily();
@@ -486,6 +543,23 @@ export async function migrate(): Promise<void> {
console.log('[migrate] Done'); console.log('[migrate] Done');
} }
// Children toggle chores with their own token, so fam-scoped collections need
// fam read rules on EXISTING installs (fresh installs get them via
// SCHEMA_PLAN). Without these, the chore lookup returns empty and the
// completions create fails relation validation ("resource cannot be found").
// Idempotent.
async function ensureFamReadRules(): Promise<void> {
const readRule = 'famId = @request.auth.famId';
for (const name of ['assigned_chores', 'completions', 'rewards']) {
const col = await getCollection(name);
if (!col) continue;
if (col.listRule !== readRule || col.viewRule !== readRule) {
await updateCollection(col.id, { ...col, listRule: readRule, viewRule: readRule });
console.log(`[migrate] Fixed read rules on ${name}.`);
}
}
}
// Add colour / icon / description to `assigned_chores` on existing installs so // Add colour / icon / description to `assigned_chores` on existing installs so
// family-admins can override a template's look per assignment. Idempotent. // family-admins can override a template's look per assignment. Idempotent.
async function ensureAssignedChoreFields(): Promise<void> { async function ensureAssignedChoreFields(): Promise<void> {
@@ -589,6 +663,17 @@ async function ensureBonusFields(): Promise<void> {
fields.push({ name: 'targetChoreIds', type: 'json', required: false }); fields.push({ name: 'targetChoreIds', type: 'json', required: false });
changed = true; changed = true;
} }
// Completion audit (bonus_configs only): who/when a goal was finished —
// stamped by the manual "Mark as complete" action or automatically when
// a `once` goal's reward is approved.
if (name === 'bonus_configs' && !has('completedBy')) {
fields.push({ name: 'completedBy', type: 'text', required: false });
changed = true;
}
if (name === 'bonus_configs' && !has('completedAt')) {
fields.push({ name: 'completedAt', type: 'date', 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) {
@@ -1101,11 +1186,12 @@ async function seedDemoCompletions(): Promise<void> {
const startDate = addDays(todayDate, -20); const startDate = addDays(todayDate, -20);
// Track per-member-per-week totals for weekly_history // Track per-member-per-week totals for weekly_history
type WeekKey = string; // "YYYY-MM-DD" (Monday) type WeekKey = string; // "YYYY-MM-DD" (week start: day after payday)
const weekTotals: Record< const weekTotals: Record<
string, string,
Record<string, { points: number; money: number; count: number }> Record<string, { points: number; money: number; count: number }>
> = {}; > = {};
const demoPayday = fam.payday ?? 1;
for (const member of members) { for (const member of members) {
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo']; const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
@@ -1115,10 +1201,14 @@ async function seedDemoCompletions(): Promise<void> {
const ds = dateStr(day); const ds = dateStr(day);
const dow = day.getUTCDay(); // 0=Sun const dow = day.getUTCDay(); // 0=Sun
// Compute week start (Monday) for weekly_history tracking // Compute week start (payday-anchored: weeks close on payday, so
const weekMonday = new Date(day); // the bucket opens the day after — same rule as weekStart()).
weekMonday.setUTCDate(weekMonday.getUTCDate() - ((weekMonday.getUTCDay() + 6) % 7)); const dowNum = day.getUTCDay(); // 0=Sun
const ws = dateStr(weekMonday); const sincePayday = (((dowNum - demoPayday) % 7) + 7) % 7;
const back = sincePayday === 0 ? 6 : sincePayday - 1;
const wsDay = new Date(day);
wsDay.setUTCDate(wsDay.getUTCDate() - back);
const ws = dateStr(wsDay);
if (!weekTotals[member.id]) weekTotals[member.id] = {}; if (!weekTotals[member.id]) weekTotals[member.id] = {};
if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 }; if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
@@ -1131,8 +1221,9 @@ async function seedDemoCompletions(): Promise<void> {
); );
for (const chore of memberChores) { for (const chore of memberChores) {
// Weekly chores: only complete on Monday (dow=1) // Weekly chores: only complete on the week's first day
if (chore.frequency === 'weekly' && dow !== 1) continue; // (day after payday).
if (chore.frequency === 'weekly' && dow !== (demoPayday + 1) % 7) continue;
const prob = dayProfile[chore.customName] || dayProfile.default; const prob = dayProfile[chore.customName] || dayProfile.default;
const rng = demoRng( const rng = demoRng(
+50
View File
@@ -0,0 +1,50 @@
import { randomBytes } from 'node:crypto';
import { createSuperClient } from '$lib/server/pocketbase';
// Child shared-device PINs. Superuser-only collection (like `otp`): every
// read/write goes through server endpoints with session-role checks, so a
// child can never read a sibling's PIN via PB rules.
export const PIN_RE = /^\d{3}$/;
export function generatePin(): string {
const n = randomBytes(2).readUIntBE(0, 2) % 1000;
return n.toString().padStart(3, '0');
}
async function getPinRow(userId: string) {
const pb = await createSuperClient();
return pb
.collection('pins')
.getFirstListItem(`userId='${userId}'`)
.catch(() => null);
}
export async function getPin(userId: string): Promise<string | null> {
const row = await getPinRow(userId);
return row?.pin || null;
}
export async function hasPin(userId: string): Promise<boolean> {
return (await getPin(userId)) !== null;
}
export async function setPin(famId: string, userId: string, pin: string): Promise<void> {
const pb = await createSuperClient();
const row = await getPinRow(userId);
if (row) {
await pb.collection('pins').update(row.id, { pin });
} else {
await pb.collection('pins').create({ famId, userId, pin });
}
}
export async function resetPin(famId: string, userId: string): Promise<string> {
const pin = generatePin();
await setPin(famId, userId, pin);
return pin;
}
export async function verifyPin(userId: string, pin: string): Promise<boolean> {
const row = await getPinRow(userId);
return !!row && row.pin === pin;
}
+34 -13
View File
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
// Superuser PB client (memoized). Reserved for server-only privileged // Superuser PB client (memoized). Reserved for server-only privileged
// operations that must bypass collection rules: creating child users, minting // operations that must bypass collection rules: creating child users, minting
// OTP-login tokens, and verifying OTPs against the superuser-only otp. // OTP-login tokens, and verifying OTPs against the superuser-only otp.
//
// NOTE: the auth token expires (prod PB showed ~36h). A stale memoized client
// goes out UNAUTHENTICATED, and PocketBase answers unauthenticated getOne()
// calls with 404 "resource wasn't found" — which the toggle path misreads as
// CHORE_GONE for every chore. So always re-auth when the stored token is no
// longer valid instead of reusing a dead client.
let superClient: PocketBase | null = null; let superClient: PocketBase | null = null;
export async function createSuperClient() { export async function createSuperClient() {
if (superClient) return superClient; if (superClient?.authStore.isValid) return superClient;
superClient = null;
const pb = new PocketBase(PB_ENDPOINT); const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false); pb.autoCancellation(false);
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD)); await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
@@ -40,29 +47,43 @@ export async function createSuperClient() {
return pb; return pb;
} }
// Run a superuser op, retrying once with a freshly-authenticated client when
// the first attempt hits an auth-shaped failure. A locally-valid token can
// still be dead server-side (restart/revoked secret rotation), and PB
// surfaces that as 401/403 — or as 404 when a viewRule then denies access.
async function withSuperRetry<T>(op: (pb: PocketBase) => Promise<T>): Promise<T> {
try {
return await op(await createSuperClient());
} catch (e: any) {
const status = e?.status;
if (status === 401 || status === 403 || status === 404) {
superClient = null;
return await op(await createSuperClient());
}
throw e;
}
}
// Superuser CRUD facade, built on the memoized SDK superuser client. All // Superuser CRUD facade, built on the memoized SDK superuser client. All
// server PB access (authenticated user + superuser) lives in this one module. // server PB access (authenticated user + superuser) lives in this one module.
export const pbAdmin = { export const pbAdmin = {
async getList(collection: string, filter = '') { async getList(collection: string, filter = '') {
const pb = await createSuperClient(); return withSuperRetry((pb) => {
const options: { filter?: string } = {}; const options: { filter?: string } = {};
if (filter) options.filter = filter; if (filter) options.filter = filter;
return pb.collection(collection).getFullList(options); return pb.collection(collection).getFullList(options);
});
}, },
async getOne(collection: string, id: string) { async getOne(collection: string, id: string) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).getOne(id));
return pb.collection(collection).getOne(id);
}, },
async create(collection: string, data: Record<string, unknown>) { async create(collection: string, data: Record<string, unknown>) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).create(data));
return pb.collection(collection).create(data);
}, },
async update(collection: string, id: string, data: Record<string, unknown>) { async update(collection: string, id: string, data: Record<string, unknown>) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).update(id, data));
return pb.collection(collection).update(id, data);
}, },
async remove(collection: string, id: string) { async remove(collection: string, id: string) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).delete(id));
return pb.collection(collection).delete(id);
} }
}; };
+156 -122
View File
@@ -5,11 +5,29 @@ import {
periodEnd, periodEnd,
nextPaydayAfter, nextPaydayAfter,
weekStart, weekStart,
weekdayInTz,
bonusWindow, bonusWindow,
completionInWindow completionInWindow
} from '@shared/timezone'; } from '@shared/timezone';
import { computeBonusProgress } from '@shared/bonus-progress'; import { computeBonusProgress } from '@shared/bonus-progress';
import { famMeta } from './fam'; import { famMeta } from './fam';
import { pbAdmin } from '$lib/server/pocketbase';
// `fams` reads via superuser: the acting token may predate RULE_OWN_FAM
// (migrate only bootstraps fresh stores), which otherwise logs a 404 and
// aborts evaluation. Fam-scoped by record id.
async function famMetaSU(famId: string) {
try {
const fam: any = await pbAdmin.getOne('fams', famId);
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveTz(fam.timezone || 'auto')
};
} catch {
return { payday: 1, paydayTime: '18:00', tz: resolveTz('auto') };
}
}
function resolveServerTz(tz?: string): string { function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto'); return resolveTz(tz || 'auto');
@@ -30,7 +48,13 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
const now = todayInTz(resolveServerTz(tz)); const now = todayInTz(resolveServerTz(tz));
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz); const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
const end = periodEnd(cfg.period, start); const end = periodEnd(cfg.period, start);
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) }; // Weeks close on payday, so a weekly window's end IS the settle date —
// nextPaydayAfter(end) would skip a whole extra week.
const settle =
weekdayInTz(new Date(end + 'T12:00:00Z'), tz) === payday
? end
: nextPaydayAfter(end, payday, tz);
return { claimable: 'payday', settleDate: settle };
} }
function targetChoreFor(cfg: any, memberId: string): string | undefined { function targetChoreFor(cfg: any, memberId: string): string | undefined {
@@ -64,7 +88,7 @@ export async function evaluateFam(pb: any, famId: string) {
try { try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {} } catch {}
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId); const { payday: paydayEval, tz: tzEval } = await famMetaSU(famId);
// Chores "due" this week per member (daily = 7, otherwise 1). Used by the // Chores "due" this week per member (daily = 7, otherwise 1). Used by the
// `percent` threshold type to compute % of chores completed. // `percent` threshold type to compute % of chores completed.
@@ -82,6 +106,15 @@ export async function evaluateFam(pb: any, famId: string) {
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
// Uniform rule, one reward row per config per member per window:
// achieved + no window reward → create; unachieved → delete the window
// reward unless it is claimed, requested, or payday-gated (those persist
// by design until the parent issues them). Only rewards dated inside the
// CURRENT window count as "already rewarded" — last week's row must never
// block this week's payout.
const windowRewards = existingRewards.filter((r: any) => completionInWindow(r, win));
const removable = (r: any) =>
r.status !== 'claimed' && r.status !== 'requested' && r.claimable !== 'payday';
let createdReward = false; let createdReward = false;
const rewardData = (memberId: string) => { const rewardData = (memberId: string) => {
@@ -104,139 +137,140 @@ export async function evaluateFam(pb: any, famId: string) {
}; };
}; };
if (cfg.target === 'individual') { // Completions counting toward one member (target-chore scoped when set).
const targetMembers = cfg.memberId const completionsFor = (memberId: string) => {
? allMembers.filter((m: any) => m.id === cfg.memberId) const memberTarget = targetChoreFor(cfg, memberId);
: allMembers; let list = periodCompletions.filter((c: any) => c.memberId === memberId);
for (const m of targetMembers) { if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
const memberTarget = targetChoreFor(cfg, m.id); // Collaborative configs only honour the shared targetChoreId.
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); if (cfg.target === 'collaborative' && cfg.targetChoreId)
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
memberCompletions = memberCompletions.filter( return list;
(c: any) => c.assignedChoreId === memberTarget };
); // Score for one member: percent of due chores, points value, or raw count.
let current = 0; const scoreFor = (memberId: string): number => {
if (cfg.type === 'threshold') { const list = completionsFor(memberId);
if (cfg.thresholdType === 'percent') {
const due = dueByMember[m.id] || 0;
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
} else {
current = memberCompletions.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === 'count') {
current = memberCompletions.length;
}
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
if (memberReward && !achieved) {
if (memberReward.status !== 'claimed') {
try {
await pb.collection('rewards').delete(memberReward.id);
} catch {}
}
continue;
}
if (memberReward) continue;
if (achieved) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true;
}
}
} else if (cfg.target === 'collaborative') {
const allMemberIds = allMembers.map((m: any) => m.id);
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
if (cfg.targetChoreId)
teamCompletions = teamCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
let total = 0;
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
total = Math.round((teamCompletions.length / totalDue) * 100); const due = dueByMember[memberId] || 0;
} else { return due > 0 ? Math.round((list.length / due) * 100) : 0;
total = teamCompletions.reduce((sum: number, c: any) => { }
return list.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
}
if (cfg.type === 'count') return list.length;
return 0;
};
const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
// Reconcile one member's window reward against their achieved state.
// Returns true when a live window reward exists afterwards.
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
const live = windowRewards.filter((r: any) => r.memberId === memberId);
if (!achieved) {
for (const r of live) {
if (!removable(r)) continue;
try {
await pb.collection('rewards').delete(r.id);
} catch {}
windowRewards.splice(windowRewards.indexOf(r), 1);
}
return false;
}
if (live.length > 0) return true;
await pb.collection('rewards').create(rewardData(memberId));
createdReward = true;
return true;
};
// Drop stale prior-window placeholders (unclaimed, non-gated) so they
// can't pile up unseen; gated/requested/claimed rows persist by design.
const purgeStale = async (memberId: string) => {
const stale = existingRewards.filter(
(r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
);
for (const r of stale) {
try {
await pb.collection('rewards').delete(r.id);
} catch {}
}
};
if (cfg.target === 'competitive') {
// Winner-takes-all: a single window reward held by the top scorer.
const scored = allMembers.map((m: any) => ({ memberId: m.id, current: scoreFor(m.id) }));
const qualified = scored.filter((s: any) => met(s.current));
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
const holder = windowRewards[0];
if (!winner) {
if (holder && removable(holder)) {
try {
await pb.collection('rewards').delete(holder.id);
} catch {}
}
} else if (!holder) {
await purgeStale(winner.memberId);
await pb.collection('rewards').create(rewardData(winner.memberId));
createdReward = true;
} else if (holder.memberId !== winner.memberId) {
if (removable(holder)) {
try {
await pb.collection('rewards').delete(holder.id);
} catch {}
await purgeStale(winner.memberId);
await pb.collection('rewards').create(rewardData(winner.memberId));
createdReward = true;
}
// A locked (claimed/requested/gated) holder keeps the crown until issued.
}
} else {
// Individual: each member against their own score. Collaborative: the
// whole team against the team total, rewarded per member.
let teamTotal = 0;
if (cfg.target === 'collaborative') {
const ids = new Set(allMembers.map((m: any) => m.id));
let team = periodCompletions.filter((c: any) => ids.has(c.memberId));
if (cfg.targetChoreId)
team = team.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
if (cfg.type === 'threshold' && cfg.thresholdType === 'percent') {
teamTotal = Math.round((team.length / totalDue) * 100);
} else if (cfg.type === 'threshold') {
teamTotal = team.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0); return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0); }, 0);
}
} else if (cfg.type === 'count') {
total = teamCompletions.length;
}
const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
if (!achieved && existingRewards.length > 0) {
for (const r of existingRewards) {
if (r.status !== 'claimed') {
try {
await pb.collection('rewards').delete(r.id);
} catch {}
}
}
continue;
}
if (achieved && existingRewards.length === 0) {
for (const m of allMembers) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true;
}
}
} else if (cfg.target === 'competitive') {
const scored = allMembers.map((m: any) => {
const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
if (memberTarget)
memberCompletions = memberCompletions.filter(
(c: any) => c.assignedChoreId === memberTarget
);
let current = 0;
if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') {
const due = dueByMember[m.id] || 0;
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
} else {
current = memberCompletions.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === 'count') { } else if (cfg.type === 'count') {
current = memberCompletions.length; teamTotal = team.length;
}
return { memberId: m.id, name: m.name, current };
});
const qualified = scored.filter(
(s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)
);
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
if (existingRewards.length > 0) {
const existing = existingRewards[0];
const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0;
if (!stillValid && existing.status !== 'claimed') {
try {
await pb.collection('rewards').delete(existing.id);
} catch {}
} }
} }
const members =
if (winner && existingRewards.length === 0) { cfg.target === 'individual' && cfg.memberId
await pb.collection('rewards').create(rewardData(winner.memberId)); ? allMembers.filter((m: any) => m.id === cfg.memberId)
createdReward = true; : allMembers;
for (const m of members) {
const achieved =
cfg.target === 'collaborative' ? met(teamTotal) : met(scoreFor(m.id));
await reconcile(m.id, achieved);
await purgeStale(m.id);
} }
} }
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) { if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
// Status write via superuser: evaluate often runs as a child token
// (fire-and-forget after a chore toggle) and bonus_configs updates
// are parent-only — the user-token write 403s and the config stays
// `active` forever. Stamp who earned it + when.
const earner =
existingRewards.find((r: any) => r.memberId)?.memberId ||
allMembers[0]?.id ||
'auto';
try { try {
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' }); await pbAdmin.update('bonus_configs', cfg.id, {
status: 'completed',
completedAt: new Date().toISOString().slice(0, 10),
completedBy: earner
});
} catch {} } catch {}
} }
} }
+86 -25
View File
@@ -1,7 +1,27 @@
import { periodWindow } from '@shared/timezone'; import { periodWindow } from '@shared/timezone';
import { famMeta } from './fam'; import { pbAdmin } from '$lib/server/pocketbase';
import { evaluateFam } from './bonuses'; import { evaluateFam } from './bonuses';
// Reads/writes the acting user may not be permitted by PB rules:
// - `fams` view (prod may predate RULE_OWN_FAM; migrate only bootstraps fresh)
// - `assigned_chores` update (parent-only rule, but children claim shared chores)
// Both stay fam-scoped: the fam record id and the chore's own famId are checked.
async function famMetaSU(famId: string) {
const fam: any = await pbAdmin.getOne('fams', famId);
const { resolveTz } = await import('@shared/timezone');
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveTz(fam.timezone || 'auto')
};
}
async function claimChore(assignedChoreId: string, famId: string, memberId: string | '') {
const chore: any = await pbAdmin.getOne('assigned_chores', assignedChoreId);
if (!chore || chore.famId !== famId) throw new Error('Chore not found');
await pbAdmin.update('assigned_chores', assignedChoreId, { memberId });
}
export async function myChores(pb: any, famId: string, memberId: string) { export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
@@ -12,7 +32,7 @@ export async function myChores(pb: any, famId: string, memberId: string) {
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
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 famMetaSU(famId);
return { return {
templates, templates,
assigned, assigned,
@@ -25,27 +45,52 @@ export async function myChores(pb: any, famId: string, memberId: string) {
}; };
} }
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) { export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) {
const { assignedChoreId, date } = body; const { assignedChoreId, date } = body;
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required'); if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
// Backdated catch-up (yesterday mode) stamps the completion for the viewed
// day, never "now" — the logical `date` always comes from the client, and
// an explicit `completedAt` is honoured when it parses (validated ISO,
// defaulting to now). Server-side period windows are always computed from
// the real current day, so a yesterday stamp can't shift window logic.
let completedAt = new Date().toISOString();
if (typeof body.completedAt === 'string') {
const t = new Date(body.completedAt).getTime();
if (Number.isFinite(t)) completedAt = new Date(t).toISOString();
}
const choreList = await pb // Chore existence is checked as superuser so a stale client ID (parent
.collection('assigned_chores') // re-saved the chores grid → old assigned rows deleted, new ids issued)
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` }); // surfaces as a clear "gone" error instead of PB's generic
const chore = choreList?.[0]; // "resource cannot be found" relation-validation 400. The child's own
// token is still used for the completion writes below (PB rules enforce).
let chore: any;
try {
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
} catch (e) {
console.error(
`[diag] toggle CHORE_GONE(getOne-fail) member=${memberId} fam=${famId} chore=${assignedChoreId} date=${date} err=${e instanceof Error ? e.message : e}`
);
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
}
if (!chore || chore.famId !== famId) {
console.error(
`[diag] toggle CHORE_GONE(fam-mismatch) member=${memberId} sessionFam=${famId} chore=${assignedChoreId} choreFam=${chore?.famId} choreMember=${chore?.memberId} date=${date}`
);
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
}
const isTodo = chore?.isTodo; const isTodo = chore?.isTodo;
const isShared = chore?.shared === true || !chore?.memberId; const isShared = chore?.shared === true || !chore?.memberId;
// Only one-off shared todos are claimed (winner takes it). Recurring
// shared chores stay unassigned so every member completes independently
// each period — claiming them permanently hides the chore from siblings.
const isClaimable = isShared && !!isTodo;
let filter: string; let filter: string;
if (isShared && !isTodo) { if (isTodo) {
// Shared non-todo: any completion in the period = already claimed
const { payday, tz } = await famMeta(pb, famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`;
} else if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`; filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else { } else {
const { payday, tz } = await famMeta(pb, famId); const { payday, tz } = await famMetaSU(famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz); const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`; filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
} }
@@ -58,11 +103,18 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isTodo && existing[0].rewardId) { if (isTodo && existing[0].rewardId) {
await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {}); await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {});
} }
// If this was a shared chore claimed by this member, un-share it // If this was a claimed one-off todo, release it back to shared.
if (isShared && existing[0].memberId === memberId) { if (isClaimable && existing[0].memberId === memberId) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId: '' }); await claimChore(assignedChoreId, famId, '');
}
// Bonus evaluation is awaited (not fire-and-forget) so failures are
// visible in the server log instead of silently swallowing missed payouts.
// The client already updates optimistically, so this doesn't block the UI.
try {
await evaluateFam(pb, famId);
} catch (e) {
console.error('[evaluateFam] after un-complete:', e);
} }
evaluateFam(pb, famId).catch(() => {});
return { completed: false }; return { completed: false };
} }
let rewardId: string | undefined; let rewardId: string | undefined;
@@ -84,14 +136,19 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
memberId, memberId,
assignedChoreId, assignedChoreId,
date, date,
completedAt: new Date().toISOString(), completedAt,
...(rewardId ? { rewardId } : {}) ...(rewardId ? { rewardId } : {})
}); });
// Claim the shared chore by setting memberId // Claim a shared one-off todo by setting memberId (recurring shared
if (isShared) { // chores stay unassigned — see isClaimable above).
await pb.collection('assigned_chores').update(assignedChoreId, { memberId }); if (isClaimable) {
await claimChore(assignedChoreId, famId, memberId);
}
try {
await evaluateFam(pb, famId);
} catch (e) {
console.error('[evaluateFam] after toggle:', e);
} }
evaluateFam(pb, famId).catch(() => {});
return { completed: true, record }; return { completed: true, record };
} }
@@ -107,8 +164,12 @@ export async function revoke(pb: any, famId: string, completionId: string) {
.getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` }); .getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` });
const chore = choreList?.[0]; const chore = choreList?.[0];
if (chore?.shared === true) { if (chore?.shared === true) {
await pb.collection('assigned_chores').update(chore.id, { memberId: '' }); await claimChore(chore.id, famId, '');
}
try {
await evaluateFam(pb, famId);
} catch (e) {
console.error('[evaluateFam] after revoke:', e);
} }
evaluateFam(pb, famId).catch(() => {});
return { revoked: true }; return { revoked: true };
} }
+19 -9
View File
@@ -76,6 +76,7 @@ export async function getProfile(pb: any, famId: string, userId: string) {
pattern: rec.pattern || '', pattern: rec.pattern || '',
themeSize: rec.themeSize || '', themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '', themeOpacity: rec.themeOpacity || '',
partyEmoji: rec.partyEmoji || '',
email: rec.email || '' email: rec.email || ''
}; };
} }
@@ -92,6 +93,8 @@ export async function updateProfile(
if (data.pattern !== undefined) patch.pattern = data.pattern; if (data.pattern !== undefined) patch.pattern = data.pattern;
if (data.themeSize !== undefined) patch.themeSize = data.themeSize; if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity; if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
if (data.partyEmoji !== undefined)
patch.partyEmoji = String(data.partyEmoji).trim().slice(0, 8) || '🐖';
if (data.email !== undefined) patch.email = data.email; if (data.email !== undefined) patch.email = data.email;
const rec = await pb.collection('users').update(userId, patch); const rec = await pb.collection('users').update(userId, patch);
return { return {
@@ -101,6 +104,7 @@ export async function updateProfile(
pattern: rec.pattern || '', pattern: rec.pattern || '',
themeSize: rec.themeSize || '', themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '', themeOpacity: rec.themeOpacity || '',
partyEmoji: rec.partyEmoji || '',
email: rec.email || '' email: rec.email || ''
}; };
} }
@@ -167,13 +171,16 @@ export async function weeklySummary(pb: any, famId: string) {
return sum + (chore?.type === 'money' ? Number(chore.value) : 0); return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0); }, 0);
let bonusMoney = 0; let bonusMoney = 0;
try { try {
const cashRewards = await pb.collection('rewards').getFullList({ // Incoming cash counts too (unclaimed/requested, e.g. achieved but
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` // not yet approved pocket money) — this is the "earned this week"
}); // figure, not just banked cash.
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); const cashRewards = await pb.collection('rewards').getFullList({
} catch {} filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'`
});
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
} catch {}
return { return {
memberId: m.id, memberId: m.id,
@@ -199,7 +206,10 @@ export async function releaseWeek(pb: any, famId: string) {
if (!fam) throw new Error('Fam not found'); if (!fam) throw new Error('Fam not found');
const wsToday = weekStart(payday, tz); const wsToday = weekStart(payday, tz);
const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz)); // Settlement lands on payday itself — the SIXTH day of the wsToday week
// (weeks close on payday), not on weekStart.
const paydayDate = addDaysStr(wsToday, 6);
const target = new Date(wallClockToUtc(paydayDate, paydayTime || '18:00', tz));
if (Date.now() < target.getTime()) { if (Date.now() < target.getTime()) {
return { return {
settled: false, settled: false,
@@ -273,7 +283,7 @@ export async function completeWeek(pb: any, famId: string) {
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
}), }),
pb.collection('rewards').getFullList({ pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` filter: `famId = '${famId}' && rewardType = 'cash' && date >= '${ws}'`
}) })
]); ]);
} catch {} } catch {}
+6 -2
View File
@@ -56,7 +56,7 @@ export function createServices(
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId) cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
}, },
completions: { completions: {
toggle: (famId: string, body: { assignedChoreId: string; date: string }) => toggle: (famId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) =>
completionsSvc.toggle(pb, famId, uid, body), completionsSvc.toggle(pb, famId, uid, body),
revoke: (famId: string, completionId: string) => revoke: (famId: string, completionId: string) =>
completionsSvc.revoke(pb, famId, completionId) completionsSvc.revoke(pb, famId, completionId)
@@ -79,7 +79,11 @@ export function createServices(
return pb.collection('bonus_configs').update(configId, updates); return pb.collection('bonus_configs').update(configId, updates);
}, },
complete: (famId: string, configId: string) => complete: (famId: string, configId: string) =>
pb.collection('bonus_configs').update(configId, { status: 'completed' }), pb.collection('bonus_configs').update(configId, {
status: 'completed',
completedAt: new Date().toISOString().slice(0, 10),
completedBy: 'admin'
}),
destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId) destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId)
}, },
settings: { settings: {
+46 -11
View File
@@ -1,49 +1,83 @@
import { todayInTz, resolveTz } from '@shared/timezone'; import { todayInTz, resolveTz, wallClockToUtc } from '@shared/timezone';
import { famMeta } from './fam'; import { famMeta } from './fam';
import { pbAdmin } from '$lib/server/pocketbase';
function resolveServerTz(tz?: string): string { function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto'); return resolveTz(tz || 'auto');
} }
export function assertPaydayUnlocked(reward: any, tz?: string) { // A `once` goal is finished the moment its reward is approved — complete the
// config here (not just in evaluateFam, which runs on toggles/revoke/payday) so
// the child dashboard stops offering it even before the next toggle.
// Config write via superuser: bonus_configs updates are parent-only.
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible
// and collectible; only the approval moment completes the goal.
async function completeOnceConfig(famId: string, reward: any) {
if (!reward?.bonusConfigId) return;
try {
const cfg: any = await pbAdmin.getOne('bonus_configs', reward.bonusConfigId);
if (!cfg || cfg.famId !== famId) return;
if (cfg.occurrence !== 'once' || cfg.status === 'completed') return;
await pbAdmin.update('bonus_configs', cfg.id, {
status: 'completed',
completedAt: new Date().toISOString().slice(0, 10),
completedBy: reward.memberId || 'admin'
});
} catch {}
}
// Payday-gated rewards (claimable 'payday', e.g. pocket money) unlock on the
// settle DATE at the fam's configured payday TIME — not midnight. Anything
// else (claimable 'immediate'/unset, e.g. manual triggers) is always claimable.
export function assertPaydayUnlocked(reward: any, tz?: string, paydayTime?: string) {
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
const today = todayInTz(resolveServerTz(tz)); const resolvedTz = resolveServerTz(tz);
if (today < reward.settleDate) { const settle = (reward.settleDate as string).slice(0, 10);
throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`); const today = todayInTz(resolvedTz);
if (today < settle) {
throw new Error(`This bonus pays out on payday (${settle}) — hang tight!`);
}
if (today === settle && paydayTime && /^\d{2}:\d{2}$/.test(paydayTime)) {
const unlock = wallClockToUtc(settle, paydayTime, resolvedTz);
if (Date.now() < unlock) {
throw new Error(`This bonus unlocks at ${paydayTime} on payday — hang tight!`);
}
} }
} }
// Member claim → status 'requested' (pending parent approval). // Member claim → status 'requested' (pending parent approval).
export async function claim(pb: any, famId: string, id: string) { export async function claim(pb: any, famId: string, id: string) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { tz } = await famMeta(pb, famId); const { tz, paydayTime } = await famMeta(pb, famId);
const found = await pb const found = await pb
.collection('rewards') .collection('rewards')
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` }); .getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
const reward = found?.[0]; const reward = found?.[0];
if (!reward) throw new Error('Reward not found'); if (!reward) throw new Error('Reward not found');
assertPaydayUnlocked(reward, tz); assertPaydayUnlocked(reward, tz, paydayTime);
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now }); return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
} }
// Admin approval → status 'claimed'. // Admin approval → status 'claimed'.
export async function approve(pb: any, famId: string, id: string) { export async function approve(pb: any, famId: string, id: string) {
return pb.collection('rewards').update(id, { const record = await pb.collection('rewards').update(id, {
status: 'claimed', status: 'claimed',
claimedAt: new Date().toISOString() claimedAt: new Date().toISOString()
}); });
await completeOnceConfig(famId, record);
return record;
} }
export async function requestAll(pb: any, famId: string, memberId: string) { export async function requestAll(pb: any, famId: string, memberId: string) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { tz } = await famMeta(pb, famId); const { tz, paydayTime } = await famMeta(pb, famId);
const rewards = await pb.collection('rewards').getFullList({ const rewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'` filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
}); });
let count = 0; let count = 0;
for (const r of rewards) { for (const r of rewards) {
try { try {
assertPaydayUnlocked(r, tz); assertPaydayUnlocked(r, tz, paydayTime);
} catch { } catch {
continue; continue;
} }
@@ -61,7 +95,8 @@ export async function issueAll(pb: any, famId: string, memberId: string) {
}); });
let count = 0; let count = 0;
for (const r of rewards) { for (const r of rewards) {
await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now }); const record = await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now });
await completeOnceConfig(famId, record);
count++; count++;
} }
return { count }; return { count };
+3 -2
View File
@@ -3,10 +3,11 @@ import { famMeta } from './fam';
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a // Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
// parametric form of `weekStart()` so we can bucket historical weeks, not // parametric form of `weekStart()` so we can bucket historical weeks, not
// just the current one. // just the current one. Weeks close on payday (open the day after).
function weekStartFor(dateStr: string, payday: number, tz: string): string { function weekStartFor(dateStr: string, payday: number, tz: string): string {
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz); const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
const back = (((wd - payday) % 7) + 7) % 7; const sincePayday = (((wd - payday) % 7) + 7) % 7;
const back = sincePayday === 0 ? 6 : sincePayday - 1;
return addDaysStr(dateStr, -back); return addDaysStr(dateStr, -back);
} }
+51 -7
View File
@@ -1,22 +1,66 @@
import type { Cookies } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
// The PocketBase JWT lives in a single cookie shared by: // PocketBase JWTs live in cookies shared by:
// - the server hooks (authRefresh -> locals.user) // - the server hooks (authRefresh -> locals.user)
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe) // - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
// httpOnly keeps the token out of reach of browser JS/XSS; the client receives // httpOnly keeps tokens out of reach of browser JS/XSS; the client receives
// the token server-side via the layout load (pbToken) and seeds pb.authStore. // the token server-side via the layout load (pbToken) and seeds pb.authStore.
// Secure flag is set in prod so it's only sent over HTTPS. // Secure flag is set in prod so it's only sent over HTTPS.
export const SESSION_COOKIE = 'pb_token'; export const SESSION_COOKIE = 'pb_token';
// Shared-device multi-session: children hold ONE cookie per account
// (`pb_token_<userId>`); `pb_active` names which one is the current session.
// Parents stay on the single `pb_token`.
export const CHILD_COOKIE_PREFIX = 'pb_token_';
export const ACTIVE_COOKIE = 'pb_active';
const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration
export function setSessionCookie(cookies: Cookies, token: string) { function cookieOpts() {
cookies.set(SESSION_COOKIE, token, { return {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax' as const,
path: '/', path: '/',
maxAge: MAX_AGE, maxAge: MAX_AGE,
secure: import.meta.env.PROD secure: import.meta.env.PROD
}); };
}
export function setSessionCookie(cookies: Cookies, token: string) {
cookies.set(SESSION_COOKIE, token, cookieOpts());
}
export function childSessionCookie(userId: string) {
return `${CHILD_COOKIE_PREFIX}${userId}`;
}
export function setChildSessionCookie(cookies: Cookies, userId: string, token: string) {
cookies.set(childSessionCookie(userId), token, cookieOpts());
}
export function clearChildSession(cookies: Cookies, userId: string) {
cookies.delete(childSessionCookie(userId), { path: '/' });
}
export function setActiveChild(cookies: Cookies, userId: string) {
cookies.set(ACTIVE_COOKIE, userId, cookieOpts());
}
export function clearActiveChild(cookies: Cookies) {
cookies.delete(ACTIVE_COOKIE, { path: '/' });
}
// Ids of every child that has a session cookie on this device.
export function scanChildSessions(cookies: Cookies): string[] {
return cookies
.getAll()
.filter((c) => c.name.startsWith(CHILD_COOKIE_PREFIX))
.map((c) => c.name.slice(CHILD_COOKIE_PREFIX.length))
.filter(Boolean);
}
// Remove every child session on this device (logout-all).
export function clearDeviceSessions(cookies: Cookies) {
for (const id of scanChildSessions(cookies)) clearChildSession(cookies, id);
clearActiveChild(cookies);
} }
export function clearSessionCookie(cookies: Cookies) { export function clearSessionCookie(cookies: Cookies) {
@@ -50,4 +94,4 @@ export function setPlatformSession(cookies: Cookies, token: string) {
export function clearPlatformSession(cookies: Cookies) { export function clearPlatformSession(cookies: Cookies) {
cookies.delete(PLATFORM_SESSION_COOKIE, { path: '/' }); cookies.delete(PLATFORM_SESSION_COOKIE, { path: '/' });
} }
+1
View File
@@ -8,4 +8,5 @@ export interface SessionUser {
pattern?: string; pattern?: string;
themeSize?: string; themeSize?: string;
themeOpacity?: string; themeOpacity?: string;
partyEmoji?: string;
} }
+11 -1
View File
@@ -15,7 +15,17 @@ export function readShortcut(): Shortcut | null {
if (typeof localStorage === 'undefined') return null; if (typeof localStorage === 'undefined') return null;
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Shortcut) : null; if (!raw) return null;
const s = JSON.parse(raw) as Shortcut;
// Self-heal the old poisoned entry: a write that fell back to
// page.params.fam while page.data.fam was null stored the PB id as
// both slug and name ({famSlug: id, famName: id}). A real slug is
// never identical to the display name AND a 15-char PB id.
if (s && s.famSlug === s.famName && /^[a-z0-9]{15}$/.test(s.famSlug || '')) {
localStorage.removeItem(KEY);
return null;
}
return s;
} catch { } catch {
return null; return null;
} }
+9 -2
View File
@@ -1,6 +1,7 @@
import { pb } from '$lib/pocketbase'; import { pb } from '$lib/pocketbase';
import type { ChatMessage, TypingRow, MentionMember } from '$lib/types'; import type { ChatMessage, TypingRow, MentionMember } from '$lib/types';
import { addAutoDismissNotice } from './notices.svelte'; import { addAutoDismissNotice } from './notices.svelte';
import { famStore } from './fam.svelte';
interface ChatInit { interface ChatInit {
famId: string; famId: string;
@@ -119,7 +120,10 @@ class ChatStore {
this.onMessage(data.action, data.record); this.onMessage(data.action, data.record);
}) })
.then((unsub) => this.unsubs.push(unsub)) .then((unsub) => this.unsubs.push(unsub))
.catch((err: Error) => console.error('[chatStore] messages subscribe failed:', err)); .catch((err: Error) => {
console.error('[chatStore] messages subscribe failed:', err);
famStore.connectionDown = true;
});
const typingSub = pb const typingSub = pb
.collection('chat_typing') .collection('chat_typing')
@@ -128,7 +132,10 @@ class ChatStore {
this.onTyping(data.action, data.record); this.onTyping(data.action, data.record);
}) })
.then((unsub) => this.unsubs.push(unsub)) .then((unsub) => this.unsubs.push(unsub))
.catch((err: Error) => console.error('[chatStore] typing subscribe failed:', err)); .catch((err: Error) => {
console.error('[chatStore] typing subscribe failed:', err);
famStore.connectionDown = true;
});
await Promise.allSettled([msgSub, typingSub]); await Promise.allSettled([msgSub, typingSub]);
} }
+146 -1
View File
@@ -35,9 +35,15 @@ class FamStore {
seasons = $state<Season[]>([]); seasons = $state<Season[]>([]);
initialized = $state(false); initialized = $state(false);
famId = $state(''); famId = $state('');
// True while the realtime SSE stream is down (subscriptions exist but the
// socket dropped, e.g. ERR_QUIC_PROTOCOL_ERROR). The SDK reconnects on its
// own; we resync missed events on PB_CONNECT (below) + browser signals.
connectionDown = $state(false);
private unsubs: (() => void)[] = []; private unsubs: (() => void)[] = [];
private destroyed = false; private destroyed = false;
private seenConnect = false;
private resyncing = false;
memberMap(): Map<string, Member> { memberMap(): Map<string, Member> {
return new Map(this.members.map((m) => [m.id, m])); return new Map(this.members.map((m) => [m.id, m]));
@@ -137,12 +143,139 @@ class FamStore {
} }
await this.subscribe(); await this.subscribe();
this.watchConnection();
this.initPromise = null; this.initPromise = null;
})(); })();
return this.initPromise!; return this.initPromise!;
} }
// Refetch every list and replace local state. Used after a realtime
// reconnect (the SDK re-establishes SSE itself but never replays events
// missed during the outage) and on browser online/visible signals.
// Acting on stale state is what produced the phantom "resource not found"
// 400s after a tab silently disconnected.
async resync() {
if (!this.initialized || this.destroyed || this.resyncing) return;
this.resyncing = true;
// Retry with backoff: a resync fired at the leading edge of a blip
// (QUIC drop, Tailscale relay flap) usually succeeds a second later.
// The dot stays amber until one attempt fully lands.
let lastErr: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt));
if (this.destroyed) break;
try {
await this.fetchAll();
if (this.destroyed) return;
// A completed resync proves the pipe is healthy again.
this.connectionDown = false;
lastErr = null;
break;
} catch (e) {
lastErr = e;
}
}
if (lastErr) {
console.error('FamStore.resync failed:', lastErr);
if (!this.destroyed) this.connectionDown = true;
}
this.resyncing = false;
}
private async fetchAll() {
const [
membersRes,
templatesRes,
assignedRes,
completionsRes,
bonusConfigsRes,
bonusTemplatesRes,
rewardsRes,
seasonsRes
] = await Promise.all([
pb.collection('users').getFullList({
filter: `famId = '${this.famId}' && role = 'child'`
}) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `(famId = '${this.famId}' || global = true)` }) as Promise<
ChoreTemplate[]
>,
pb.collection('assigned_chores').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
AssignedChore[]
>,
pb.collection('completions').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
Completion[]
>,
pb.collection('bonus_configs').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
BonusConfig[]
>,
pb.collection('bonus_templates').getFullList({ filter: `(famId = '${this.famId}' || global = true)` }) as Promise<
BonusTemplate[]
>,
pb.collection('rewards').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
Reward[]
>,
pb.collection('seasons').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
Season[]
>
]);
if (this.destroyed) return;
this.members = membersRes;
this.templates = templatesRes;
this.assigned = assignedRes;
this.completions = completionsRes;
this.bonusConfigs = bonusConfigsRes;
this.bonusTemplates = bonusTemplatesRes;
this.rewards = rewardsRes;
this.seasons = seasonsRes;
}
private connWatchers: (() => void)[] = [];
private watchConnection() {
if (typeof window === 'undefined' || this.connWatchers.length) return;
// SDK-level: PB_CONNECT fires on every (re)connect, including the first.
pb.realtime
.subscribe('PB_CONNECT', () => {
if (this.destroyed) return;
this.connectionDown = false;
if (this.seenConnect) {
// Reconnect after an outage — replay what we missed.
this.resync().catch(() => {});
}
this.seenConnect = true;
})
.then((unsub) => {
this.connWatchers.push(unsub);
})
.catch(() => {
// We never even got the connect signal — surface it.
if (!this.destroyed) this.connectionDown = true;
});
const onDisc = pb.realtime.onDisconnect;
pb.realtime.onDisconnect = (active: string[]) => {
try {
onDisc?.(active);
} catch {}
// Only flag drops with live subscriptions (not our own cleanup).
if (active.length > 0 && !this.destroyed) this.connectionDown = true;
};
// Browser-level: sleep/wake and offline/online can kill SSE without the
// SDK noticing promptly. Don't touch the flag here — resync() sets it
// from the actual outcome (clearing it early is what left the dot
// green while the console showed errors).
const onOnline = () => {
this.resync().catch(() => {});
};
const onVisible = () => {
if (document.visibilityState === 'visible') this.resync().catch(() => {});
};
window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onVisible);
this.connWatchers.push(() => window.removeEventListener('online', onOnline));
this.connWatchers.push(() => document.removeEventListener('visibilitychange', onVisible));
}
private async subscribe() { private async subscribe() {
const subs: { collection: CollectionName; filter: string }[] = [ const subs: { collection: CollectionName; filter: string }[] = [
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` }, { collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
@@ -175,6 +308,9 @@ class FamStore {
}) })
.catch((err: Error) => { .catch((err: Error) => {
console.error(`[famStore] subscribe failed for ${collection}:`, err); console.error(`[famStore] subscribe failed for ${collection}:`, err);
// A failed subscribe IS the outage the console shows while
// the dot stayed green — flag it; PB_CONNECT/resync clears it.
if (!this.destroyed) this.connectionDown = true;
}); });
}); });
@@ -185,7 +321,12 @@ class FamStore {
// and from PB subscribe SSE for multi-user realtime. // and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => { const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list]; // A create for an id we already hold (optimistic add + SSE echo) is
// an update, not a second row.
if (action === 'create')
return list.some((x) => x.id === record.id)
? list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
: [record, ...list];
if (action === 'update') if (action === 'update')
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)); return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
if (action === 'delete') return list.filter((x) => x.id !== record.id); if (action === 'delete') return list.filter((x) => x.id !== record.id);
@@ -233,6 +374,10 @@ class FamStore {
this.destroyed = true; this.destroyed = true;
for (const unsub of this.unsubs) unsub(); for (const unsub of this.unsubs) unsub();
this.unsubs = []; this.unsubs = [];
for (const stop of this.connWatchers) stop();
this.connWatchers = [];
this.seenConnect = false;
this.connectionDown = false;
this.initialized = false; this.initialized = false;
} }
} }
@@ -0,0 +1,60 @@
import { isSharedDevice, setSharedDevice } from '$lib/client/shared-device';
import { notices } from './notices.svelte';
// Per-device shared-computer mode (NOT a fam setting — see
// lib/client/shared-device.ts). One reactive home for the flag so the fam
// layout (switcher + idle-lock gating) and the dashboard hero toggle stay in
// sync. Anyone holding the device can flip it; the server sees the cookie
// mirror as data.sharedDevice.
class SharedDeviceStore {
on = $state(
typeof localStorage !== 'undefined' ? isSharedDevice() : false
);
async toggle() {
this.on = !this.on;
setSharedDevice(this.on);
if (!this.on) return;
// Just enabled — remind about PIN state. Kids without a PIN can't be
// picked until one is set (picker shows "ask a parent"); kids with one
// get a nudge to remember it. Values never exposed — only set/unset.
try {
const res = await fetch('/api/pins/status');
if (!res.ok) throw new Error();
const s = await res.json();
if (Array.isArray(s.children)) {
// Parent view: roster of who still needs a PIN.
const missing = s.children.filter((c: any) => !c.hasPin).map((c: any) => c.name);
const ready = s.children.filter((c: any) => c.hasPin).map((c: any) => c.name);
if (missing.length > 0) {
notices.warning(
'Shared mode on',
`Still need a PIN: ${missing.join(', ')} (set in Settings → Members).` +
(ready.length > 0 ? ` Ready: ${ready.join(', ')}.` : '')
);
} else {
notices.success(
'Shared mode on',
`PINs ready for ${ready.length > 0 ? ready.join(', ') : 'everyone'}. Remind the kids!`
);
}
} else if (typeof s.hasPin === 'boolean') {
// Child view: only their own state.
if (s.hasPin) {
notices.success('Shared mode on', 'Remember your 3-digit PIN to switch back in!');
} else {
notices.warning(
'Shared mode on',
'You need a PIN first. Ask a parent to set one up.'
);
}
} else {
notices.info('Shared mode on', 'This computer now uses profile PINs.');
}
} catch {
notices.info('Shared mode on', 'This computer now uses profile PINs.');
}
}
}
export const sharedDevice = new SharedDeviceStore();
+10 -2
View File
@@ -20,7 +20,11 @@ import {
PawPrint, PawPrint,
Guitar, Guitar,
Medal, Medal,
Wrench Wrench,
PenLine,
SprayCan,
Droplets,
CookingPot
} from '@lucide/svelte'; } from '@lucide/svelte';
export const ICON_MAP: Record<string, any> = { export const ICON_MAP: Record<string, any> = {
@@ -45,7 +49,11 @@ export const ICON_MAP: Record<string, any> = {
PawPrint, PawPrint,
Guitar, Guitar,
Medal, Medal,
Wrench Wrench,
PenLine,
SprayCan,
Droplets,
CookingPot
}; };
export const ICON_NAMES = Object.keys(ICON_MAP); export const ICON_NAMES = Object.keys(ICON_MAP);
+2
View File
@@ -146,6 +146,8 @@ export interface BonusConfig {
criteriaValue?: number; criteriaValue?: number;
period?: BonusPeriod; period?: BonusPeriod;
status: BonusStatus; status: BonusStatus;
completedBy?: string;
completedAt?: string;
targetChoreId?: string; targetChoreId?: string;
targetChoreIds?: Record<string, string>; targetChoreIds?: Record<string, string>;
isPocketMoney?: boolean; isPocketMoney?: boolean;
+7 -2
View File
@@ -1,8 +1,13 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { getPlatformFlags } from '$lib/server/platform'; import { getPlatformFlags } from '$lib/server/platform';
import { scanChildSessions } from '$lib/server/session';
// Platform settings are public (read-only): feature flags ride along with // Platform settings are public (read-only): feature flags ride along with
// every page's data so any component can deduce them via page.data.platformFlags. // every page's data so any component can deduce them via page.data.platformFlags.
export const load: LayoutServerLoad = async () => { export const load: LayoutServerLoad = async (event) => {
return { platformFlags: await getPlatformFlags() }; return {
platformFlags: await getPlatformFlags(),
// Children with session cookies on THIS device (shared-computer picker).
deviceChildIds: scanChildSessions(event.cookies)
};
}; };
+18
View File
@@ -2,8 +2,26 @@
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import NoticeDialog from '$lib/components/NoticeDialog.svelte'; import NoticeDialog from '$lib/components/NoticeDialog.svelte';
import { famStore } from '$lib/stores/fam.svelte';
let { children } = $props(); let { children } = $props();
// Last-resort net: PocketBase SDK fetch failures (the minified `send()`
// frame in prod stacks) otherwise surface as unhandled rejections with no
// UI signal. Network-shaped ones flip the TopNav status dot; the fam
// layout's resync clears it once the pipe is healthy again.
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const msg = String((e.reason as any)?.message || e.reason || '');
if (
/TypeError|Failed to fetch|Load failed|NetworkError|network|offline|ERR_QUIC|ERR_NETWORK|ERR_INTERNET/i.test(
msg
)
) {
famStore.connectionDown = true;
}
});
}
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
+195 -100
View File
@@ -1,7 +1,19 @@
<script lang="ts"> <script lang="ts">
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import FamDone from '$lib/components/FamDone.svelte'; import FamDone from '$lib/components/FamDone.svelte';
import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons'; import {
homeIcon,
choresIcon,
checkCircleIcon,
chevronRightIcon,
dashboardIcon,
chatIcon,
gemIcon,
lockIcon,
coinsIcon,
shieldIcon,
laptopIcon
} from '$lib/components/icons';
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut'; import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
import CheckboxGrid from '$lib/components/CheckboxGrid.svelte'; import CheckboxGrid from '$lib/components/CheckboxGrid.svelte';
@@ -40,7 +52,7 @@
<div class="hero-content"> <div class="hero-content">
<span class="hero-kicker">The family chore app</span> <span class="hero-kicker">The family chore app</span>
<h1 class="hero-title"><FamDone /></h1> <h1 class="hero-title"><FamDone /></h1>
<p class="hero-sub">Every chore checked. Every allowance earned — on autopilot.</p> <p class="hero-sub">Building <span class="text-green-400">healthy habbits</span> together!</p>
<div class="hero-actions"> <div class="hero-actions">
<a class="hero-cta" href="/signup">Create my family</a> <a class="hero-cta" href="/signup">Create my family</a>
<a class="hero-cta-ghost" href="/pricing">See pricing</a> <a class="hero-cta-ghost" href="/pricing">See pricing</a>
@@ -51,20 +63,25 @@
<div class="intro-inner"> <div class="intro-inner">
<div class="intro-copy"> <div class="intro-copy">
<span class="pill">For busy families</span> <span class="pill">For busy families</span>
<h1>Chores done. <span class="accent">Allowance earned.</span></h1> <h1>Chores done. <span class="accent">Get rewarded.</span></h1>
<p class="lede"> <p class="lede">
<FamDone /> turns everyday household chores into points and pocket money. Assign the chores, <FamDone /> lets you choose chores and rewards with your kids.
let your kids see their progress live, and <FamDone /> calculates the allowance automatically
— no spreadsheets, no nagging.
</p> </p>
<p class="lede">
Because life is fun, <FamDone /> makes it easy for the tricky things become part of your daily
routine.
</p>
<!-- <p> - letting them become
empowered and take ownership of the things they forget about - You might learn a thing or two too!</p> -->
<ul class="hero-ticks"> <ul class="hero-ticks">
<li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li> <li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
<li> <li>
<span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code <span class="tick">{@html checkCircleIcon}</span>Kids join with their own device or
family computer
</li> </li>
<li> <li>
<span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, <span class="tick">{@html checkCircleIcon}</span>Get a free trial and see if it's right
automatically for you
</li> </li>
</ul> </ul>
</div> </div>
@@ -73,11 +90,6 @@
<h2>Start your family</h2> <h2>Start your family</h2>
<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">
<a class="flex gap-2 self-center align-center justify-center" href="/pricing"
>See pricing {@html chevronRightIcon}</a
>
</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>
</p> </p>
@@ -86,116 +98,108 @@
<div class="explainer"> <div class="explainer">
<div class="steps"> <div class="steps">
<article class="step"> <article class="step">
<span class="step-num">1</span> <div class="step-head">
<h3>Create your family</h3> <span class="step-media">
<span class="step-icon">{@html homeIcon}</span>
<span class="step-num">1</span>
</span>
<h3>Create your family</h3>
</div>
<p> <p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite Setting up chores isn't a bore! Your kids join in seconds with an <strong
code. >emailess</strong
> invite code.
</p> </p>
</article> </article>
<article class="step"> <article class="step">
<span class="step-num">2</span> <div class="step-head">
<h3>Kids do the work</h3> <span class="step-media">
<span class="step-icon">{@html choresIcon}</span>
<span class="step-num">2</span>
</span>
<h3>Kids do the work</h3>
</div>
<p> <p>
They see today's chores as a simple card board and tick them off as they go — points are They see today's chores as a simple card board and tick them off as they go — points are
added instantly. added instantly.
</p> </p>
</article> </article>
<article class="step"> <article class="step">
<span class="step-num">3</span> <div class="step-head">
<h3>Allowance pays out</h3> <span class="step-media">
<span class="step-icon">{@html coinsIcon}</span>
<span class="step-num">3</span>
</span>
<h3>Payday!</h3>
</div>
<p> <p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep If a kid meets their criteria - they can claim their pocket money by annoying you like a
it fun. noisey flying unicorn.
</p> </p>
</article> </article>
</div> </div>
</div> </div>
</section> </section>
<!-- EXPLAINER -->
<!-- <section class="explainer">
<div class="steps">
<article class="step">
<span class="step-num">1</span>
<h3>Create your family</h3>
<p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite
code.
</p>
</article>
<article class="step">
<span class="step-num">2</span>
<h3>Kids do the work</h3>
<p>
They see today's chores as a simple card board and tick them off as they go — points are
added instantly.
</p>
</article>
<article class="step">
<span class="step-num">3</span>
<h3>Allowance pays out</h3>
<p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it
fun.
</p>
</article>
</div>
</section> -->
<!-- FEATURES -->
<section class="features"> <section class="features">
<div class="features-inner"> <div class="features-inner">
<h2>Everything a family needs</h2> <h2>The BIG Idea</h2>
<div class="feature-grid"> <div class="feature-grid">
<article class="feature"> <article class="feature" style="--ic-bg:#e0e7ff; --ic-fg:#4338ca">
<h3>Chore board</h3> <div class="feature-head">
<span class="feature-icon">{@html dashboardIcon}</span>
<h3>Chore board</h3>
</div>
<p>Simple and intuitive chore board for parents to assign tasks to their kids.</p>
</article>
<article class="feature" style="--ic-bg:#ccfbf1; --ic-fg:#0d9488">
<div class="feature-head">
<span class="feature-icon">{@html chatIcon}</span>
<h3>Chat</h3>
</div>
<p> <p>
Reusable chore templates and a simple assignment grid. Build once, reuse every week. Real-time chat for parents to communicate with their kids - so you can congratulate
them!
</p> </p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#ede9fe; --ic-fg:#7c3aed">
<h3>Live progress</h3> <div class="feature-head">
<span class="feature-icon">{@html shieldIcon}</span>
<h3>Trust!</h3>
</div>
<p>The whole experience is about exercising trust together.</p>
</article>
<article class="feature" style="--ic-bg:#fef3c7; --ic-fg:#d97706">
<div class="feature-head">
<span class="feature-icon">{@html gemIcon}</span>
<h3>Rewards &amp; bonuses</h3>
</div>
<p> <p>
Realtime sync across the whole family — see points and progress update the moment a As a parent you can dish out behaviour rewards or pocket money boosters - in a place
chore is done. which is yours to create together.
</p> </p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#e0f2fe; --ic-fg:#0284c7">
<h3>Automatic allowance</h3> <div class="feature-head">
<p> <span class="feature-icon">{@html laptopIcon}</span>
Points convert to money using your family's own rules. No mental maths, no arguments. <h3>Phone free friendly</h3>
</p> </div>
<p>You dont need a mobile or an app - just a browser.</p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#e2e8f0; --ic-fg:#475569">
<h3>Rewards &amp; bonuses</h3> <div class="feature-head">
<span class="feature-icon">{@html lockIcon}</span>
<h3>Private by design</h3>
</div>
<p> <p>
Kids can spend points on rewards, and you can run a monthly bonus for the top earner. We don't read your data, your messages, or sell anything - your family and rules is your
</p> property.
</article>
<article class="feature">
<h3>Kid-friendly</h3>
<p>
A simple, colourful interface kids love — big buttons, clear feedback, their own space.
</p>
</article>
<article class="feature">
<h3>Private by design</h3>
<p>
Everything is scoped to your family. Kids join with an invite code and stay in your
family.
</p> </p>
</article> </article>
</div> </div>
<div class="cta"> <div class="cta">
<p>Ready to make chores painless?</p> <p>Ready to get the kids involved?</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
class="hover:underline self-center items-center justify-center flex gap-2"
href="/pricing">or see pricing {@html chevronRightIcon}</a
>
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -555,22 +559,72 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 14px; border-radius: 14px;
padding: 1.5rem; padding: 1.5rem;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
} }
.step-num { .step:hover {
transform: translateY(-3px);
border-color: #c7d2fe;
box-shadow: 0 12px 24px rgba(67, 56, 202, 0.1);
}
.step-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.step-media {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.step-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2rem; width: 3rem;
height: 2rem; height: 3rem;
border-radius: 50%; border-radius: 12px;
background: #4338ca; background: linear-gradient(135deg, #4338ca, #6d28d9);
color: #fff; color: #fff;
font-weight: 700; box-shadow: 0 6px 14px rgba(67, 56, 202, 0.25);
margin-bottom: 0.75rem; }
.step-icon :global(svg) {
width: 1.5em;
height: 1.5em;
}
.step-num {
position: absolute;
top: -0.4rem;
right: -0.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
background: #fbbf24;
color: #1e1b4b;
font-weight: 800;
font-size: 0.72rem;
border: 2px solid #fff;
} }
.step h3 { .step h3 {
margin: 0 0 0.5rem; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
text-align: right;
}
.step h3::after {
content: '';
display: block;
width: 100%;
height: 3px;
border-radius: 2px;
background: #4338ca;
margin-top: 0.45rem;
} }
.step p { .step p {
margin: 0; margin: 0;
@@ -596,11 +650,52 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 12px; border-radius: 12px;
padding: 1.25rem; padding: 1.25rem;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.feature:hover {
transform: translateY(-3px);
border-color: #d1d5db;
box-shadow: 0 14px 28px rgba(67, 56, 202, 0.12);
}
.feature-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.6rem;
height: 2.6rem;
border-radius: 10px;
background: var(--ic-bg);
color: var(--ic-fg);
}
.feature-icon :global(svg) {
width: 1.35em;
height: 1.35em;
} }
.feature h3 { .feature h3 {
margin: 0 0 0.4rem; margin: 0;
font-size: 1rem; font-size: 1rem;
color: #111827; color: #111827;
text-align: right;
}
.feature h3::after {
content: '';
display: block;
width: 100%;
height: 3px;
border-radius: 2px;
background: var(--ic-fg);
margin-top: 0.45rem;
} }
.feature p { .feature p {
margin: 0; margin: 0;
+90 -6
View File
@@ -1,9 +1,10 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient, createSuperClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services'; import { createServices, type ChatActor } from '$lib/server/services';
import { ensureFamAccess } from '$lib/server/access'; import { ensureFamAccess } from '$lib/server/access';
import { seedDemoCompletions } from '$lib/server/migrate'; import { seedDemoCompletions } from '$lib/server/migrate';
import { getPlatformFlags } from '$lib/server/platform'; import { getPlatformFlags } from '$lib/server/platform';
import { scanChildSessions } from '$lib/server/session';
async function paydayCheck(famId: string, pbToken: string) { async function paydayCheck(famId: string, pbToken: string) {
try { try {
@@ -56,7 +57,62 @@ export async function load(event) {
const session = event.locals.user; const session = event.locals.user;
const role = session?.role || 'child'; const role = session?.role || 'child';
const isParent = role === 'parent'; const isParent = role === 'parent';
const pbToken = event.cookies.get('pb_token') || ''; const pbToken = event.locals.pbToken || '';
const deviceChildIds = scanChildSessions(event.cookies);
// ── Shared-device picker mode ──
// The device holds child sessions but none is active (per-profile logout).
// Anything except the join flow lands on the standalone picker.
const paramFam = event.params.fam;
const isJoinPage = (event.url.pathname || '').split('/').includes('join');
if (!session && deviceChildIds.length > 0 && !isJoinPage) {
if (!(event.url.pathname || '').endsWith('/switch')) {
throw redirect(303, `/${encodeURIComponent(paramFam)}/switch`);
}
let pickerFamName = paramFam || '';
let pickerChildren: {
id: string;
name: string;
color: string;
username: string;
}[] = [];
try {
const pb = await createSuperClient();
const fam = await pb
.collection('fams')
.getFirstListItem(`slug='${paramFam}'`)
.catch(() => null);
if (fam) {
pickerFamName = fam.name || fam.slug;
const users = await pb.collection('users').getFullList({
filter: `id in ('${deviceChildIds.join("','")}') && role='child'`
});
pickerChildren = (users || []).map((u: any) => ({
id: u.id,
name: u.name || u.username || '',
color: u.color || '#6366f1',
username: u.username || ''
}));
}
} catch {}
return {
famSlug: paramFam || '',
session: null,
isParent,
role,
famId: '',
chat: null,
pbToken: '',
fam: null,
famAccess: { disabled: false, mode: 'none', reason: '' },
demoMode: (await getPlatformFlags()).demo,
picker: true,
pickerFamName,
pickerChildren,
deviceChildIds,
lockMins: 0
};
}
let famId = ''; let famId = '';
let chat: { let chat: {
@@ -70,6 +126,7 @@ export async function load(event) {
mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', mode: 'none' as 'none' | 'code' | 'sub' | 'canceled',
reason: '' reason: ''
}; };
let lockMins = 0;
if (session && pbToken) { if (session && pbToken) {
famId = session.famId; famId = session.famId;
@@ -77,16 +134,34 @@ export async function load(event) {
chat = await resolveChatIdentity(session, pbToken); chat = await resolveChatIdentity(session, pbToken);
// fams is superadmin-only (non-realtime). Fetched server-side for both // fams is superadmin-only (non-realtime). Fetched server-side for both
// roles; also recomputes + persists the derived `active` flag. // roles; also recomputes + persists the derived `active` flag.
const res = await ensureFamAccess(famId).catch(() => null); // Retried: a post-deploy PB blip here blanks `fam`, which both hides
// data AND disables the canonical slug redirect below (letting /{id}
// URLs persist).
let res: any = null;
for (let i = 0; i < 3 && !res; i++) {
if (i > 0) await new Promise((r) => setTimeout(r, 400 * i));
res = await ensureFamAccess(famId).catch(() => null);
}
if (res) { if (res) {
fam = res.fam; fam = res.fam;
famAccess = res.access; famAccess = res.access;
} }
// Shared-device idle lock setting (0 = off; missing row defaults to 10
// min). Superuser read — the settings rules are parent-oriented and
// children must see it too.
try {
const pb = await createSuperClient();
const settings = await pb
.collection('settings')
.getFullList({ filter: `famId='${famId}'` })
.catch(() => []);
const row = (settings as any[])?.[0];
lockMins = row && row.lockMins != null ? Number(row.lockMins) : 10;
} catch {}
// Canonical URL: the [fam] segment must be the family SLUG, never the PB // Canonical URL: the [fam] segment must be the family SLUG, never the PB
// id. If someone lands on /{famId}/... (a stale shortcut, bookmark, or a // id. If someone lands on /{famId}/... (a stale shortcut, bookmark, or a
// login that fell back to the id), rewrite the first path segment to the // login that fell back to the id), rewrite the first path segment to the
// slug so the id is replaced everywhere it'd otherwise persist. // slug so the id is replaced everywhere it'd otherwise persist.
const paramFam = event.params.fam;
const canonicalSlug = fam?.slug; const canonicalSlug = fam?.slug;
if (canonicalSlug && paramFam && paramFam !== canonicalSlug) { if (canonicalSlug && paramFam && paramFam !== canonicalSlug) {
const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/'; const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/';
@@ -100,7 +175,7 @@ export async function load(event) {
return { return {
// Canonical fam slug — from the URL param ([fam] routes). Client code // Canonical fam slug — from the URL param ([fam] routes). Client code
// reads page.data.famSlug; never copy it into local $state. // reads page.data.famSlug; never copy it into local $state.
famSlug: event.params.fam || '', famSlug: paramFam || '',
session: session session: session
? { ? {
famId: session.famId, famId: session.famId,
@@ -121,6 +196,15 @@ export async function load(event) {
pbToken, pbToken,
fam, fam,
famAccess, famAccess,
demoMode demoMode,
picker: false,
pickerFamName: '',
pickerChildren: [],
deviceChildIds,
lockMins,
// Per-device shared-computer flag (cookie mirror of the localStorage
// flag the TopNav toggle writes). Server can only hint — the client
// re-reads localStorage on hydration.
sharedDevice: event.cookies.get('fam_shared_device') === '1'
}; };
} }
+243 -60
View File
@@ -6,9 +6,11 @@
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
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, SharedPicker } from '$lib/components';
import { chatIcon } from '$lib/components/icons'; import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut'; import { recordShortcut } from '$lib/shortcut';
import { installLockTracking, lockDue } from '$lib/client/lock';
import { sharedDevice } from '$lib/stores/shared-device.svelte';
import { themeShades } from '$lib/theme'; import { themeShades } from '$lib/theme';
import '$lib/theme-patterns.css'; import '$lib/theme-patterns.css';
import { themeDraft } from '$lib/stores/theme.svelte'; import { themeDraft } from '$lib/stores/theme.svelte';
@@ -29,10 +31,50 @@
// Pattern size in vw units ('' = untouched → pattern class default). // Pattern size in vw units ('' = untouched → pattern class default).
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || '')); let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : ''); let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
// Shared-device picker: standalone when the device has child sessions but
// none is active (layout load redirects here); overlay when the idle lock
// fires or the top-nav switcher is opened.
const pickerMode = $derived(!!data.picker);
let pickerOpen = $state(false);
let lockTimer: ReturnType<typeof setInterval> | null = null;
let deviceProfiles = $derived(
(data.deviceChildIds || [])
.map((id) => famStore.members.find((m) => m.id === id))
.filter(Boolean)
.map((m: any) => ({ id: m.id, name: m.name, color: m.color, username: m.username }))
);
// Idle lock (children on shared devices only): return to the picker after
// `lockMins` of no interaction. localStorage-backed (see lib/client/lock.ts)
// so a closed browser still trips the lock on next launch.
$effect(() => {
const isChild = data.session?.role === 'child';
const lockMins = Number(data.lockMins) || 0;
if (!isChild || !sharedDevice.on || lockMins <= 0 || data.picker) return;
installLockTracking();
if (lockDue(lockMins)) pickerOpen = true;
if (!lockTimer) {
lockTimer = setInterval(() => {
if (lockDue(lockMins)) pickerOpen = true;
}, 15_000);
}
return () => {
if (lockTimer) {
clearInterval(lockTimer);
lockTimer = null;
}
};
});
let session = $state<Session | null>(data.session); let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent); let isParent = $state(data.isParent);
let role = $state(data.role); let role = $state(data.role);
let isDemo = $state(data.demoMode); // Demo UI restrictions apply only inside the demo family itself — the
// global `demo` flag must never degrade real families (setting it in
// prod once hid every fam's dashboard + settings links).
let isDemo = $derived(
!!data.demoMode && (page.data.famSlug ?? page.params.fam) === 'showboaters'
);
let famName = $derived( let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
); );
@@ -41,6 +83,14 @@
// 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 hasAuth = $derived(!!data.session); let hasAuth = $derived(!!data.session);
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings')); let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
// Expired-token zombie state: public PB rules still let logged-out users
// READ everything, so the dashboard looks alive while every write fails.
// Say so explicitly (join/switch/picker flows render their own UI).
let showLoggedOut = $derived(
!data.session &&
!pickerMode &&
!['join', 'switch'].includes(page.url.pathname.split('/')[2] || '')
);
// ── 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
@@ -134,11 +184,16 @@
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}. // their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
// The handle comes from the session (not page.params) so jump-back-in lands // The handle comes from the session (not page.params) so jump-back-in lands
// on the user's own dashboard even from the bare /{fam} page. // on the user's own dashboard even from the bare /{fam} page.
// The slug MUST come from the fam record — page.params.fam may be the PB
// id (login ID-fallback / stale link) when page.data.fam failed to load,
// and recording that poisons the shortcut (famSlug === famName === id).
$effect(() => { $effect(() => {
if (!page.data.session) return; if (!page.data.session) return;
const slug = page.data.fam?.slug as string;
if (!slug) return;
recordShortcut({ recordShortcut({
famSlug: (page.data.fam?.slug as string) || page.params.fam || '', famSlug: slug,
famName: (page.data.fam?.name as string) || page.params.fam || '', famName: (page.data.fam?.name as string) || slug,
userName: page.data.session.username || page.params.username || '' userName: page.data.session.username || page.params.username || ''
}); });
}); });
@@ -194,66 +249,124 @@
}); });
</script> </script>
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}> <!-- layout.svelte picker-mode branch -->
<div {#if pickerMode}
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}" <main class="picker-stage">
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`} <SharedPicker
> standalone
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} /> famSlug={data.famSlug}
<TopNav famName={data.pickerFamName}
role={data.session ? role : ''} profiles={data.pickerChildren || []}
seasons={famStore.seasons.filter((s) => s.active !== false)} />
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''} </main>
{:else}
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
<div
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
> >
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat"> >
{@html chatIcon} <Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
{#if chatStore.unread > 0} <TopNav
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span> role={data.session ? role : ''}
{/if} seasons={famStore.seasons.filter((s) => s.active !== false)}
</button> announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
</TopNav> connected={!famStore.connectionDown}
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#if !hasAuth}
<h2>You need to be logged in to view this page</h2>
{:else if activating}
<div class="spinner" aria-hidden="true"></div>
<strong>Activating your subscription…</strong>
<span>Payment received — this usually only takes a few seconds.</span>
{:else}
<strong>Access paused</strong>
<span>
{isParent
? 'Add an access code or resume your subscription to keep using FamDone.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
> >
{claimToast} — view dashboard → <button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
</a> {@html chatIcon}
{#if chatStore.unread > 0}
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
{/if}
</button>
{#if data.session && sharedDevice.on && (data.deviceChildIds?.length || 0) > 0}
<button
class="kid-switch"
onclick={() => (pickerOpen = true)}
aria-label="Switch user (shared device)"
title="Switch user"
>
<span class="kid-switch-dot" style="background:{data.session.memberColor || '#6366f1'}"
></span>
</button>
{/if}
</TopNav>
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if showLoggedOut}
<div class="disabled-overlay loggedout-overlay">
<div class="disabled-card">
<strong>You're logged out</strong>
<span>
Your session expired, so everything below is read-only — toggles and
claims won't work until you log back in.
</span>
<span class="loggedout-actions">
<a class="btn-primary" href="/login">Parent log in</a>
{#if page.params.username}
<a
class="btn-secondary"
href={`/${data.famSlug || page.params.fam}/join/${page.params.username}`}
>Rejoin as {page.params.username}</a
>
{/if}
</span>
<span class="hint">Kids: re-open your invite link, or ask a parent to send it again.</span>
</div>
</div>
{/if}
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#if !hasAuth}
<h2>You need to be logged in to view this page</h2>
{:else if activating}
<div class="spinner" aria-hidden="true"></div>
<strong>Activating your subscription…</strong>
<span>Payment received — this usually only takes a few seconds.</span>
{:else}
<strong>Access paused</strong>
<span>
{isParent
? 'Add an access code or resume your subscription to keep using FamDone.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
>
{claimToast} — view dashboard →
</a>
{/if}
<Footer sidebar />
</div>
{#if chatStore.open}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
></button>
{/if}
<Chat {role} />
{#if pickerOpen && deviceProfiles.length > 0}
<SharedPicker
profiles={deviceProfiles}
activeId={data.session?.userId}
famSlug={data.famSlug}
oncancel={() => (pickerOpen = false)}
/>
{/if}
{#if famStore.connectionDown}
<div class="conn-toast" role="status">Reconnecting… live updates paused</div>
{/if} {/if}
<Footer sidebar />
</div> </div>
{#if chatStore.open} {/if}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
></button>
{/if}
<Chat {role} />
</div>
<style> <style>
.claim-toast { .claim-toast {
@@ -274,6 +387,21 @@
.claim-toast:hover { .claim-toast:hover {
background: #d97706; background: #d97706;
} }
.conn-toast {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #fff;
padding: 0.5rem 1.1rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
z-index: 95;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
animation: fadein 0.2s;
}
@media (max-width: 1023px) { @media (max-width: 1023px) {
.claim-toast { .claim-toast {
left: 56px; left: 56px;
@@ -389,6 +517,34 @@
color: #6b7280; color: #6b7280;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Logged-out card must be clickable (the paused overlay is display-only). */
.loggedout-overlay {
pointer-events: auto;
}
.loggedout-actions {
display: flex;
gap: 0.6rem;
justify-content: center;
margin-top: 0.4rem;
}
.loggedout-actions a {
border-radius: 8px;
padding: 0.5rem 1.1rem;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
}
.loggedout-actions .btn-primary {
background: #6366f1;
color: #fff;
}
.loggedout-actions .btn-secondary {
background: #eef2ff;
color: #4338ca;
}
.disabled-card .hint {
font-size: 0.78rem;
}
.chat-toggle { .chat-toggle {
position: relative; position: relative;
width: 40px; width: 40px;
@@ -402,6 +558,33 @@
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.kid-switch {
position: relative;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.kid-switch:hover {
background: #e5e7eb;
}
.kid-switch-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e2e8f0;
}
.picker-stage {
min-height: 100vh;
width: 100%;
background: whitesmoke;
}
.chat-toggle:hover { .chat-toggle:hover {
background: #e5e7eb; background: #e5e7eb;
color: #4338ca; color: #4338ca;
+209 -67
View File
@@ -5,11 +5,28 @@
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 { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { sharedDevice } from '$lib/stores/shared-device.svelte';
import { monitorIcon } from '$lib/components/icons';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone'; import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
Chart.register(...registerables); Chart.register(...registerables);
// Time-of-day greeting, picked once per load (not a ticking clock).
const GREETINGS: { from: number; to: number; lines: string[] }[] = [
{ from: 5, to: 12, lines: ['Good morning', 'Rise and shine', 'Morning, crew'] },
{ from: 12, to: 17, lines: ['Good afternoon', 'Back on it', 'Afternoon push'] },
{ from: 17, to: 22, lines: ['Good evening', 'Welcome back', 'Evening shift'] },
{ from: 22, to: 29, lines: ['Night owls', 'Still at it', 'Burning the midnight oil'] }
];
let greeting = $state(
(() => {
const h = new Date().getHours();
const band = GREETINGS.find((g) => (h >= g.from && h < g.to) || (g.to > 24 && h < g.to - 24)) || GREETINGS[0];
return band.lines[Math.floor(Math.random() * band.lines.length)];
})()
);
let { data } = $props(); let { data } = $props();
let famSlug = $derived(page.data.famSlug ?? page.params.fam); let famSlug = $derived(page.data.famSlug ?? page.params.fam);
@@ -90,6 +107,39 @@
} }
return map; return map;
}); });
// ─── All-time per-member stats ───
let allTimeByMember = $derived.by(() => {
const map = new Map<string, { points: number; cash: number; chores: number }>();
for (const m of trend as any[]) {
const pts = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.points) || 0), 0);
const cash = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.cash) || 0), 0);
const chores = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.chores) || 0), 0);
map.set(m.memberId, { points: pts, cash, chores });
}
return map;
});
let goalsAllTimeByMember = $derived.by(() => {
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
const map = new Map<string, { count: number; names: string[] }>();
for (const r of rewards as any[]) {
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
const entry = map.get(r.memberId) || { count: 0, names: [] };
entry.count++;
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
map.set(r.memberId, entry);
}
return map;
});
let pocketMoneyAllTimeByMember = $derived.by(() => {
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
const map = new Map<string, number>();
for (const r of rewards as any[]) {
if (!r.bonusConfigId || !pmIds.has(r.bonusConfigId)) continue;
map.set(r.memberId, (map.get(r.memberId) || 0) + 1);
}
return map;
});
// ─── Trend chart window: show current month, expand a month at a time up to 3. ─── // ─── Trend chart window: show current month, expand a month at a time up to 3. ───
let trend = $derived.by(() => { let trend = $derived.by(() => {
@@ -154,25 +204,27 @@
function todayPotentialFor(memberId: string) { function todayPotentialFor(memberId: string) {
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length; 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. // All-time completion rate for a member: all chores ever done ÷ (current
function dailyPointsPotential(memberId: string) { // weekly potential × weeks active). Bounded and meaningful, unlike the old
return assigned // points-per-day average (bonus points + weeklies done in one day could
.filter((a: any) => a.memberId === memberId && a.frequency === 'daily') // push it past a "perfect day" — e.g. Jethro's 100%).
.reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0); function overallRateFor(memberId: string): { done: number; total: number; pct: number } {
} const line = trend.find((m: any) => m.memberId === memberId);
// Days elapsed in the current week (min 1), for a running daily average. const weeks = line?.weeks || [];
function daysElapsed(): number { const done = weeks.reduce((sum: number, w: any) => sum + (Number(w.chores) || 0), 0);
const days = summary?.daysInWeek || []; const potential = weeklyPotentialFor(memberId);
if (weeks.length === 0 || potential <= 0) return { done, total: 0, pct: 0 };
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
const today = todayInTz(famTz); const today = todayInTz(famTz);
return Math.max(1, days.filter((d: string) => d <= today).length); const weeksActive = Math.max(
} 1,
// Average daily points from chore completions this week. Math.floor(
function avgDailyPointsFor(s: any): number { (new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
const total = Object.values(s.dayPoints || {}).reduce( (7 * 86400000)
(sum: number, v: any) => sum + (Number(v) || 0), ) + 1
0
); );
return total / daysElapsed(); const total = potential * weeksActive;
return { done, total, pct: Math.min(100, Math.round((done / total) * 100)) };
} }
// Today's completion count — tolerant of UTC vs fam-local date keys. // Today's completion count — tolerant of UTC vs fam-local date keys.
function todayDone(s: any): number { function todayDone(s: any): number {
@@ -193,6 +245,7 @@
if (!summary?.summaries?.length) return; if (!summary?.summaries?.length) return;
if (choresCanvas && summary.daysInWeek?.length) { if (choresCanvas && summary.daysInWeek?.length) {
// daysInWeek runs payday+1..payday (weeks close on payday).
const days = summary.daysInWeek; const days = summary.daysInWeek;
const labels = days.map((d: string) => const labels = days.map((d: string) =>
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' }) new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
@@ -255,10 +308,11 @@
} }
const scoreCanvas = donutCanvases[`${s.memberId}:score`]; const scoreCanvas = donutCanvases[`${s.memberId}:score`];
if (scoreCanvas) { if (scoreCanvas) {
const overall = overallRateFor(s.memberId);
donutCharts[`${s.memberId}:score`] = makeDonut( donutCharts[`${s.memberId}:score`] = makeDonut(
scoreCanvas, scoreCanvas,
avgDailyPointsFor(s), overall.done,
dailyPointsPotential(s.memberId), overall.total,
s.memberColor s.memberColor
); );
} }
@@ -309,20 +363,32 @@
</script> </script>
<ViewHeader <ViewHeader
title={page.data.fam?.name || famSlug} title={greeting}
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'} subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`}
hero hero
> >
<!-- All-time family totals, large in the header --> <!-- Shared-computer toggle (left) + all-time totals (right), one line -->
<div class="vh-stats grow text-right"> <div class="vh-toprow">
<span class="vh-stat"> <button
<span class="vh-num pts">{allTimePts.toLocaleString()}</span> class="shared-hero-toggle"
<span class="vh-lbl">points all time</span> class:on={sharedDevice.on}
</span> onclick={() => sharedDevice.toggle()}
<span class="vh-stat"> aria-pressed={sharedDevice.on}
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span> title={sharedDevice.on ? 'Shared computer mode is ON' : 'Mark this as a shared computer'}
<span class="vh-lbl">cash earned all time</span> >
</span> {@html monitorIcon}
<span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span>
</button>
<div class="vh-stats 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>
</div> </div>
</ViewHeader> </ViewHeader>
@@ -378,10 +444,10 @@
{@const tdTotal = todayPotentialFor(s.memberId)} {@const tdTotal = todayPotentialFor(s.memberId)}
{@const tdDone = todayDone(s)} {@const tdDone = todayDone(s)}
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0} {@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
{@const scPot = dailyPointsPotential(s.memberId)} {@const sc = overallRateFor(s.memberId)}
{@const scVal = avgDailyPointsFor(s)}
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
{@const goals = goalsByMember.get(s.memberId)} {@const goals = goalsByMember.get(s.memberId)}
{@const goalsAll = goalsAllTimeByMember.get(s.memberId)}
{@const at = allTimeByMember.get(s.memberId)}
<div class="column col-member" style="--mc: {s.memberColor}"> <div class="column col-member" style="--mc: {s.memberColor}">
<div class="member-head"> <div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span> <span class="dot" style="background:{s.memberColor}"></span>
@@ -403,34 +469,22 @@
</div> </div>
<div class="donut-wrap"> <div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas> <canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{scPct}%</div> <div class="donut-center" style="color:{s.memberColor}">{sc.pct}%</div>
<span class="donut-label">Avg/day</span> <span class="donut-label">Overall</span>
</div> </div>
</div> </div>
<div class="member-stats"> <div class="member-stats compact">
<div class="stat-row"> <div class="mstat" title="Points this week: {s.pointsEarned} · Cash: £{Number(s.moneyEarned || 0).toFixed(2)} · Pocket money: {pocketMoneyByMember.get(s.memberId) ?? 0}">
<span class="stat-lbl">Points</span> <span class="mstat-tag">Week</span>
<span class="stat-val pts">{s.pointsEarned}</span> <span class="mstat-main"><b class="pts">{s.pointsEarned}</b> pts · <b class="cash">£{Number(s.moneyEarned || 0).toFixed(2)}</b> · {wkDone}/{wkTotal}</span>
</div> {#if (goals?.count ?? 0) > 0}<span class="mstat-goals" title={goals?.names.join(' · ')}>🎉{goals?.count}</span>{/if}
<div class="stat-row">
<span class="stat-lbl">Cash</span>
<span class="stat-val cash">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Done</span>
<span class="stat-val">{wkDone}/{wkTotal}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Goals 🎉</span>
<span class="stat-val goals" title={goals?.names.join(' · ')}
>{goals?.count ?? 0}</span
>
</div>
<div class="stat-row">
<span class="stat-lbl">Pocket Money</span>
<span class="stat-val pm">{pocketMoneyByMember.get(s.memberId) ?? 0}</span>
</div>
</div> </div>
<div class="mstat dim" title="Points all time: {at?.points ?? s.pointsEarned} · Cash: £{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)} · Pocket money: {pocketMoneyAllTimeByMember.get(s.memberId) ?? 0}">
<span class="mstat-tag">All time</span>
<span class="mstat-main"><b>{at?.points ?? s.pointsEarned}</b> pts · <b>£{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)}</b> · {sc.done}/{sc.total}</span>
{#if (goalsAll?.count ?? 0) > 0}<span class="mstat-goals" title={goalsAll?.names.join(' · ')}>🎉{goalsAll?.count}</span>{/if}
</div>
</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -491,6 +545,38 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
opacity: 0.85; opacity: 0.85;
} }
/* Shared-computer toggle (left) + all-time totals (right), one line */
.vh-toprow {
flex: 1 1 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
margin-top: 0.5rem;
}
.shared-hero-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
background: rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 999px;
padding: 0.4rem 0.95rem;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.shared-hero-toggle:hover {
background: rgba(255, 255, 255, 0.28);
color: #fff;
}
.shared-hero-toggle.on {
background: #fff;
color: #4338ca;
border-color: transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* ── Chores card totals ── */ /* ── Chores card totals ── */
.chores-totals { .chores-totals {
@@ -608,7 +694,7 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1; border-top: 4px solid #6366f1;
border-radius: 12px; border-radius: 12px;
padding: 0.75rem; padding: 0.9rem;
background: #fafbff; background: #fafbff;
} }
.col-member { .col-member {
@@ -623,6 +709,7 @@
gap: 0.4rem; gap: 0.4rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-bottom: 0.35rem;
} }
.dot { .dot {
display: inline-block; display: inline-block;
@@ -655,18 +742,18 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.85rem; gap: 0.7rem;
margin: 3rem 0; margin: 1.1rem 0 1.2rem;
} }
.donut-wrap { .donut-wrap {
position: relative; position: relative;
width: 62px; width: 54px;
height: 62px; height: 54px;
flex-shrink: 0; flex-shrink: 0;
} }
.donut-wrap canvas { .donut-wrap canvas {
max-width: 62px; max-width: 54px;
max-height: 62px; max-height: 54px;
} }
.donut-center { .donut-center {
position: absolute; position: absolute;
@@ -694,15 +781,70 @@
.member-stats { .member-stats {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.45rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.member-stats.compact .mstat {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: #374151;
background: rgba(255, 255, 255, 0.65);
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 0.45rem 0.65rem;
white-space: nowrap;
overflow: hidden;
}
.member-stats.compact .mstat.dim {
opacity: 0.75;
}
.mstat-tag {
font-size: 0.6rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9ca3af;
flex-shrink: 0;
}
.mstat-main {
overflow: hidden;
text-overflow: ellipsis;
}
.mstat-main b {
font-weight: 800;
}
.mstat-main b.pts {
color: #7c3aed;
}
.mstat-main b.cash {
color: #059669;
}
.mstat-goals {
margin-left: auto;
flex-shrink: 0;
font-weight: 700;
color: #059669;
}
.stat-row { .stat-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.8rem; font-size: 0.8rem;
} }
.stat-group-lbl {
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #9ca3af;
margin-top: 0.35rem;
}
.stat-group-lbl:first-child {
margin-top: 0;
}
.stat-lbl { .stat-lbl {
color: #6b7280; color: #6b7280;
font-weight: 500; font-weight: 500;
@@ -70,12 +70,13 @@ export async function load(event) {
} }
} }
const pbToken = event.cookies.get('pb_token') || ''; const pbToken = event.locals.pbToken || '';
const famId = session?.famId || ''; const famId = session?.famId || '';
// When a demo parent visits a child's route, session.id is the parent's PB // When a demo parent visits a child's route, session.id is the parent's PB
// record. Look up the actual child user by username so chores load correctly. // record. Look up the actual child user by username so chores load correctly.
const childId = demoParentVisitingChild const childId = demoParentVisitingChild
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]?.id || '' ? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]
?.id || ''
: session?.id || ''; : session?.id || '';
const empty = { const empty = {
@@ -86,6 +87,7 @@ export async function load(event) {
verified: false, verified: false,
memberName: session?.name || '', memberName: session?.name || '',
memberColor: session?.color || '', memberColor: session?.color || '',
partyEmoji: (session as any)?.partyEmoji || '',
templates: [] as never[], templates: [] as never[],
assigned: [] as never[], assigned: [] as never[],
completions: [] as never[], completions: [] as never[],
@@ -110,6 +112,7 @@ export async function load(event) {
verified: true, verified: true,
memberName: session?.name || '', memberName: session?.name || '',
memberColor: session?.color || '', memberColor: session?.color || '',
partyEmoji: (session as any)?.partyEmoji || '',
templates: chores.templates || [], templates: chores.templates || [],
assigned: chores.assigned || [], assigned: chores.assigned || [],
completions: chores.completions || [], completions: chores.completions || [],
@@ -183,4 +186,4 @@ export const actions = {
return { error: e instanceof Error ? e.message : 'Failed to trigger' }; return { error: e instanceof Error ? e.message : 'Failed to trigger' };
} }
} }
}; };
+626 -78
View File
@@ -17,13 +17,13 @@
clockIcon, clockIcon,
giftIcon, giftIcon,
handCoinsIcon, handCoinsIcon,
moneyIcon,
coinsIcon, coinsIcon,
walletIcon, walletIcon,
chevronLeftIcon, chevronLeftIcon,
chevronRightIcon chevronRightIcon
} from '$lib/components/icons'; } from '$lib/components/icons';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types'; import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import PiggyBank from '@lucide/svelte/icons/piggy-bank';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { import {
weekStart as tzWeekStart, weekStart as tzWeekStart,
@@ -84,6 +84,18 @@
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today (c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
); );
} }
// Yesterday's completions surface in the admin view for revoke — but never
// from the week's first day (yesterday would be payday = last week).
let yesterdayParent = $derived(addDaysStr(today, -1));
let yesterdayParentInWeek = $derived(
!!summary?.weekStart ? yesterdayParent >= (summary.weekStart as string).slice(0, 10) : true
);
function yesterdayCompletionsFor(memberId: string) {
if (!yesterdayParentInWeek) return [];
return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === yesterdayParent
);
}
function choreNameFor(assignedChoreId: string): string { function choreNameFor(assignedChoreId: string): string {
const a = parentAssigned.find((x: any) => x.id === assignedChoreId); const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
@@ -174,13 +186,14 @@
}); });
} }
// Goals completed feed for the admin overview — every materialized bonus // Goals completed feed for the admin overview — this week's materialized
// reward (prize / points / cash), newest first. Undoing the underlying // bonus rewards (prize / points / cash), newest first. Undoing the
// chore/points deletes the unclaimed reward server-side, so this list // underlying chore/points deletes the unclaimed reward server-side, so
// shrinks again in realtime via famStore. // this list shrinks again in realtime via famStore.
let parentGoalsCompleted = $derived.by(() => let parentGoalsCompleted = $derived.by(() => {
parentRewards const ws = (summary?.weekStart as string) || today;
.filter((r: any) => r.bonusConfigId) return parentRewards
.filter((r: any) => r.bonusConfigId && (r.date || '').slice(0, 10) >= ws)
.map((r: any) => { .map((r: any) => {
const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId); const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId);
const m = parentMembers.find((x: any) => x.id === r.memberId); const m = parentMembers.find((x: any) => x.id === r.memberId);
@@ -199,8 +212,8 @@
}; };
}) })
.sort((a: any, b: any) => (b.date || '').localeCompare(a.date || '')) .sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
.slice(0, 10) .slice(0, 10);
); });
// ─── Child View (kanban) ─── // ─── Child View (kanban) ───
let memberId = $state(data.memberId || ''); let memberId = $state(data.memberId || '');
@@ -208,6 +221,15 @@
let famId = $state(data.famId || ''); let famId = $state(data.famId || '');
let memberName = $state(data.memberName || ''); let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1'); let memberColor = $state(data.memberColor || '#6366f1');
// Party mascot: live member record first (updates without re-login),
// server load as fallback, pig as default.
let partyEmoji = $derived(
(famStore.initialized
? (famStore.members as any[]).find((m) => m.id === memberId)?.partyEmoji
: null) ||
(data as any).partyEmoji ||
'🐖'
);
let editingName = $state(false); let editingName = $state(false);
let nameInput = $state(''); let nameInput = $state('');
let showColorPicker = $state(false); let showColorPicker = $state(false);
@@ -242,11 +264,43 @@
let claimError = $state(''); let claimError = $state('');
let todayChild = $derived(todayInTz(famTz)); let todayChild = $derived(todayInTz(famTz));
let togglingIds = $state<string>(''); let togglingIds = $state<Set<string>>(new Set());
// ─── Kid-created todos ───
let showTodoModal = $state(false);
let newTodoName = $state('');
let todoSaving = $state(false);
let todoError = $state('');
async function submitTodo() {
const name = newTodoName.trim();
if (!name || todoSaving) return;
todoSaving = true;
todoError = '';
try {
const res = await memberApi.createTodo(name);
// Instant UI — the SSE echo dedupes on id via applyRecord.
famStore.applyRecord('assigned_chores', res.record, 'create');
newTodoName = '';
showTodoModal = false;
} catch (e) {
todoError = e instanceof Error ? e.message : 'Could not add todo';
} finally {
todoSaving = false;
}
}
// ─── Payday countdown (child view) ─── // ─── Payday countdown (child view) ───
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
let eowFired = $state(false); let eowFired = $state(false);
// Nyan-style payday party: rainbow banner + confetti when payday unlocks.
let paydayParty = $state(false);
let partyTimer: ReturnType<typeof setTimeout> | undefined;
function startPaydayParty() {
paydayParty = true;
fire();
if (partyTimer) clearTimeout(partyTimer);
partyTimer = setTimeout(() => (paydayParty = false), 15000);
}
$effect(() => { $effect(() => {
const id = setInterval(() => (nowMs = Date.now()), 1000); const id = setInterval(() => (nowMs = Date.now()), 1000);
return () => clearInterval(id); return () => clearInterval(id);
@@ -268,6 +322,7 @@
if (!pbToken || !famId) return; if (!pbToken || !famId) return;
eowFired = true; eowFired = true;
memberApi.payday(famId).catch(() => {}); memberApi.payday(famId).catch(() => {});
startPaydayParty();
}); });
function paydayWeekStart(): string { function paydayWeekStart(): string {
@@ -289,11 +344,43 @@
let weekEnd = $derived(addDays(weekStart, 6)); let weekEnd = $derived(addDays(weekStart, 6));
const isCurrentWeek = $derived(weekStart === currentWeek); const isCurrentWeek = $derived(weekStart === currentWeek);
// ─── Yesterday catch-up mode (child only) ───
// Before 12pm fam-local, and only when yesterday is still inside the
// current week (never across payday — e.g. payday Sunday → no yesterdays
// on Monday), the hero offers a "forgot yesterday?" button that flips the
// kanban to yesterday's date so missed chores can be marked late.
let yesterdayStr = $derived(addDaysStr(todayChild, -1));
let yesterdayInWeek = $derived(yesterdayStr >= currentWeek);
let famHour = $derived.by(() => {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: famTz,
hour: 'numeric',
hour12: false
}).formatToParts(new Date(nowMs));
return Number(parts.find((p) => p.type === 'hour')?.value ?? 0) % 24;
} catch {
return new Date(nowMs).getHours();
}
});
let yesterdayAvailable = $derived(role === 'child' && yesterdayInWeek && famHour < 12);
let viewDate = $state<'today' | 'yesterday'>('today');
// Viewed day for the kanban: today normally, yesterday in catch-up mode.
let activeDate = $derived(
viewDate === 'yesterday' && yesterdayAvailable ? yesterdayStr : todayChild
);
let inYesterday = $derived(activeDate === yesterdayStr && yesterdayAvailable);
// Snap back to today when the mode stops being valid (past noon, or the
// day rolled over) so stale yesterday state can never linger.
$effect(() => {
if (!yesterdayAvailable && viewDate === 'yesterday') viewDate = 'today';
});
const daysLeft = $derived( const daysLeft = $derived(
Math.max( Math.max(
0, 0,
Math.round( Math.round(
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - // Payday closes the week on weekStart+6 (weeks run payday+1..payday).
(new Date(addDays(weekStart, 6) + 'T00:00:00').getTime() -
new Date(todayIso + 'T00:00:00').getTime()) / new Date(todayIso + 'T00:00:00').getTime()) /
86400000 86400000
) )
@@ -358,10 +445,10 @@
}); });
let dailyPending = $derived( let dailyPending = $derived(
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild)) memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, activeDate))
); );
let weeklyPending = $derived( let weeklyPending = $derived(
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild)) memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, activeDate))
); );
// Traffic light: days until todo deadline (only non-emoji todos) // Traffic light: days until todo deadline (only non-emoji todos)
@@ -379,17 +466,16 @@
return 'blue'; return 'blue';
} }
let completedToday = $derived( let completedToday = $derived(
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild) completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === activeDate)
); );
let weeklyTotal = $derived.by(() => { let weeklyTotal = $derived.by(() => {
// Must match the pocket-money % threshold definition: all assigned // Must match the pocket-money % threshold definition (and the family
// non-todo chores for the member (no season filter), weighted by the // dashboard Done denominator): only the member's own chores count —
// number of days in the displayed period — so the hero total agrees // personal assignments + shared chores they claimed. Unclaimed shared
// with the Goals progress instead of showing a different denominator. // chores stay doable in the kanban but out of every denominator, so
const chores = assigned.filter( // all views agree instead of drifting (e.g. 1/37 here vs 1/30 there).
(a) => (a.memberId === null || a.memberId === memberId) && !a.isTodo const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
);
const periodDays = const periodDays =
Math.round( Math.round(
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) / (new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) /
@@ -406,6 +492,27 @@
(c.date?.slice(0, 10) || c.date) <= weekEnd (c.date?.slice(0, 10) || c.date) <= weekEnd
) )
); );
// Viewed-week daily activity for the hero background effect: completions
// per day across the 7-day window, plotted as a blurred white line.
let heroWeekActivity = $derived.by(() => {
const counts: number[] = [];
for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i);
counts.push(weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length);
}
return counts;
});
let heroSpark = $derived.by(() => {
const max = Math.max(1, ...heroWeekActivity);
const pts = heroWeekActivity.map((v, i) => ({
x: 20 + i * ((700 - 40) / 6),
y: 100 - (v / max) * 72
}));
return {
line: pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '),
pts
};
});
let weeklyDone = $derived(weekCompletions.length); let weeklyDone = $derived(weekCompletions.length);
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0); let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
@@ -477,6 +584,15 @@
return sum + (chore?.type === 'money' ? Number(chore.value) : 0); return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0) }, 0)
); );
// Week's cash-reward earnings (pocket money, bonuses) count while still
// pending AND once claimed — auto-created money-todo rewards are skipped
// (already counted via the chore completion above).
let weekRewardCash = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'cash' && !autoRewardIds.has(r.id))
.reduce((sum, r) => sum + Number(r.value), 0)
);
let weekCashTotal = $derived(weekChoreCash + weekRewardCash);
let weekChorePoints = $derived.by(() => let weekChorePoints = $derived.by(() =>
weekCompletions.reduce((sum, c) => { weekCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId); const chore = assigned.find((a) => a.id === c.assignedChoreId);
@@ -516,9 +632,14 @@
(r.status === 'unclaimed' || r.status === 'requested') (r.status === 'unclaimed' || r.status === 'requested')
) )
); );
// Payday-gated bonus rewards unlock on their settleDate (stamped server-side). // Payday-gated bonus rewards unlock on their settleDate at the fam's payday
// time (stamped server-side) — not midnight on the day.
function paydayLocked(r: any) { function paydayLocked(r: any) {
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate; if (r.claimable !== 'payday' || !r.settleDate) return false;
const settle = (r.settleDate as string).slice(0, 10);
if (todayChild < settle) return true;
if (todayChild > settle) return false;
return nowMs < new Date(wallClockToUtc(settle, paydayTime, famTz)).getTime();
} }
let weekBonusTallies = $derived.by(() => { let weekBonusTallies = $derived.by(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
@@ -527,18 +648,19 @@
const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label; const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label;
map.set(name, (map.get(name) || 0) + 1); map.set(name, (map.get(name) || 0) + 1);
} }
// Only badge recurring/multiple bonuses — a single occurrence (×1) is // Badge every bonus earned this week — including a single occurrence
// already obvious from the wallet, so it just adds noise here. // (×1): the hero is the child's only at-a-glance signal, so nothing
return [...map.entries()].map(([name, count]) => ({ name, count })).filter((t) => t.count > 1); // earned should stay invisible here.
return [...map.entries()].map(([name, count]) => ({ name, count }));
}); });
let anyWeekBonuses = $derived(weekRewards.length > 0); let anyWeekBonuses = $derived(weekRewards.length > 0);
// ─── Threshold Goals (bonus configs the member is chasing) ─── // ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => { let thresholdGoals = $derived.by(() => {
// Member's assigned chores (non-todo) — used to compute period potential. // Member's own chores (non-todo) — personal + claimed shared, never
const myChores = assigned.filter( // unclaimed shared — used to compute period potential. Matches
(a) => (a.memberId === null || a.memberId === memberId) && !a.isTodo // weeklyTotal above, the family dashboard, and the server's % definition.
); const myChores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
return bonusConfigs return bonusConfigs
.filter((cfg) => { .filter((cfg) => {
if (cfg.status !== 'active') return false; if (cfg.status !== 'active') return false;
@@ -741,7 +863,7 @@
'December' 'December'
]; ];
function headDate(): Date { function headDate(): Date {
return new Date(todayInTz(famTz) + 'T00:00:00'); return new Date(activeDate + 'T00:00:00');
} }
function headDay(): number { function headDay(): number {
return headDate().getDate(); return headDate().getDate();
@@ -815,18 +937,29 @@
? c.assignedChoreId === chore.id && ? c.assignedChoreId === chore.id &&
(c.date?.slice(0, 10) || c.date) >= weekStart && (c.date?.slice(0, 10) || c.date) >= weekStart &&
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7) (c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === todayChild; : c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === activeDate;
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-')); const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
return optimistic || completions.find(match); return optimistic || completions.find(match);
} }
async function toggle(chore: AssignedChore) { async function toggle(chore: AssignedChore) {
if (accessDisabled) return; if (accessDisabled) return;
if (!pbToken) {
showToast('Logged out — please log in again to update chores.');
return;
}
if (chore.isTodo && isTodoExpired(chore)) return; if (chore.isTodo && isTodoExpired(chore)) return;
if (togglingIds) return; if (togglingIds.has(chore.id)) return;
togglingIds = chore.id; togglingIds = new Set(togglingIds).add(chore.id);
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild); const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, activeDate);
// Backdated (yesterday) completions are stamped for yesterday — never
// today's wall-clock time — so period/window logic can't double-count.
const stampDate = activeDate;
const stampAt =
stampDate === todayChild
? new Date().toISOString()
: new Date(wallClockToUtc(stampDate, '12:00', famTz)).toISOString();
if (wasCompleted) { if (wasCompleted) {
const existing = findCompletion(chore); const existing = findCompletion(chore);
if (existing) { if (existing) {
@@ -840,42 +973,51 @@
famId, famId,
memberId, memberId,
assignedChoreId: chore.id, assignedChoreId: chore.id,
date: todayChild, date: stampDate,
completedAt: new Date().toISOString() completedAt: stampAt
} as Completion, } as Completion,
'create' 'create'
); );
} }
try { try {
await memberApi.toggleCompletion(famId, chore.id, todayChild); await memberApi.toggleCompletion(famId, chore.id, stampDate, stampAt);
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id); const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) { if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete'); famStore.applyRecord('completions', optimistic, 'delete');
} }
} catch (e) { } catch (e) {
if (wasCompleted) { // A stale assigned ID (parent re-saved chores while this tab was
famStore.applyRecord( // open in the background) leaves the chore untogglable. init() is
'completions', // a no-op when already initialized, so force a resync() to drop
{ // the dead card. Never re-add completions for the gone chore —
id: 'revert-' + chore.id + '-' + Date.now(), // that is what kept the error reoccurring after "refresh".
famId, const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
memberId, if (optimistic) {
assignedChoreId: chore.id, famStore.applyRecord('completions', optimistic, 'delete');
date: todayChild,
completedAt: new Date().toISOString()
} as Completion,
'create'
);
} else {
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
}
} }
const msg = e instanceof Error ? e.message : String(e);
if (/CHORE_GONE/.test(msg)) {
// Drop the dead card synchronously so a second tap can't re-enter
// before the resync round-trip finishes (the repeated 400s).
famStore.applyRecord('assigned_chores', chore, 'delete');
console.error(
`[diag] toggle CHORE_GONE(drop-card) member=${memberId} fam=${famId} chore=${chore.id}`
);
}
try {
await famStore.resync();
} catch {}
showToast(
/CHORE_GONE/.test(msg)
? 'This chore was changed — list refreshed.'
: 'Toggle failed — please try again.'
);
console.error('Toggle failed:', e); console.error('Toggle failed:', e);
} finally { } finally {
togglingIds = ''; const next = new Set(togglingIds);
next.delete(chore.id);
togglingIds = next;
} }
} }
@@ -950,6 +1092,7 @@
<Card cols={1} title="Members"> <Card cols={1} title="Members">
{#each parentMembers as m} {#each parentMembers as m}
{@const todays = todayCompletionsFor(m.id)} {@const todays = todayCompletionsFor(m.id)}
{@const yesterdays = yesterdayCompletionsFor(m.id)}
{@const done = todays.length} {@const done = todays.length}
{@const s = memberInSummary(m.id)} {@const s = memberInSummary(m.id)}
{@const pm = parentBonusConfigs.find( {@const pm = parentBonusConfigs.find(
@@ -1004,6 +1147,31 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if yesterdays.length > 0}
<p class="section-label">Yesterday</p>
<ul class="done-list yesterday-list">
{#each yesterdays as c, i}
<li class="done-item" class:odd={i % 2 === 1}>
<span class="done-icon">{@html checkCircleIcon}</span>
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
<span class="yesterday-tag">yesterday</span>
<form
method="POST"
action="?/revoke"
use:enhance={() => {
return async (args) => handleResult(args);
}}
class="revoke-form"
>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke"
>{@html revokeIcon}</button
>
</form>
</li>
{/each}
</ul>
{/if}
</div> </div>
{/each} {/each}
</Card> </Card>
@@ -1069,7 +1237,12 @@
{/if} {/if}
</Card> </Card>
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}> <Card
cols={1}
title="Claims"
accent={hasClaimableRewards ? '#f97316' : undefined}
class={hasClaimableRewards ? 'claims-card' : ''}
>
{#if claimableRewards().length === 0} {#if claimableRewards().length === 0}
<div class="claims-empty"> <div class="claims-empty">
{@html checkCircleIcon}<span>No outstanding claims</span> {@html checkCircleIcon}<span>No outstanding claims</span>
@@ -1208,7 +1381,7 @@
{/if} {/if}
{#if showCountdown} {#if showCountdown}
<div class="payday-countdown"> <div class="payday-countdown">
<div class="pd-icon">{@html moneyIcon}</div> <div class="pd-icon"><PiggyBank size={44} strokeWidth={2} /></div>
<div class="pd-body"> <div class="pd-body">
<p class="pd-title">Payday is today!</p> <p class="pd-title">Payday is today!</p>
<p class="pd-num"> <p class="pd-num">
@@ -1222,8 +1395,31 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if paydayParty}
<div class="payday-party" role="status" onclick={() => (paydayParty = false)}>
<span class="party-cat">{partyEmoji}</span>
<div class="party-body">
<p class="party-title">PAYDAY!!</p>
<p class="party-sub">Your cash is ready — go collect it! 🎉</p>
</div>
<span class="party-stars">🌈⭐🌈</span>
</div>
{/if}
<!-- HERO --> <!-- HERO -->
<header class="hero"> <header class="hero">
<svg class="hero-bg" viewBox="0 0 700 120" preserveAspectRatio="none" aria-hidden="true">
<polyline
points={heroSpark.line}
fill="none"
stroke="#fff"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
{#each heroSpark.pts as p}
<circle cx={p.x} cy={p.y} r="5" fill="#fff" />
{/each}
</svg>
<div class="hero-top"> <div class="hero-top">
<div class="hero-id"> <div class="hero-id">
<span <span
@@ -1347,18 +1543,29 @@
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
{#if anyWeekBonuses} <div class="hero-bonus-slot">
{#if weekBonusTallies.length > 0} {#if anyWeekBonuses && weekBonusTallies.length > 0}
<div class="hero-pills"> <div class="hero-pills">
{#each weekBonusTallies as t} {#each weekBonusTallies as t}
<span class="hero-pill">🎉 {t.name} ×{t.count}</span> <span class="hero-pill">🎉 {t.name} ×{t.count}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
{#if isCurrentWeek}
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
{:else} {:else}
<span class="hero-null">no bonuses earned yet</span> <button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
{/if}
{#if inYesterday}
<button class="hero-now hero-yesterday-on" onclick={() => (viewDate = 'today')}>
<span>← Back to today</span>
</button>
{:else if yesterdayAvailable}
<button class="hero-now hero-yesterday" onclick={() => (viewDate = 'yesterday')}>
<span>Forgot to mark a chore yesterday?</span>
</button>
{/if} {/if}
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
</div> </div>
<nav class="hero-nav"> <nav class="hero-nav">
@@ -1377,7 +1584,7 @@
<!-- WEEK-TO-DATE TILES --> <!-- WEEK-TO-DATE TILES -->
<div class="tiles"> <div class="tiles">
<div class="tile tile-cash"> <div class="tile tile-cash">
<span class="tile-val">£{weekChoreCash.toFixed(2)}</span> <span class="tile-val">£{weekCashTotal.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-pts"> <div class="tile tile-pts">
@@ -1485,6 +1692,16 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if inYesterday}
<div class="yesterday-banner">
<span
>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span
>
<button class="yesterday-back" onclick={() => (viewDate = 'today')}
>Back to today →</button
>
</div>
{/if}
<div class="kanban"> <div class="kanban">
<div class="column col-daily"> <div class="column col-daily">
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2> <h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
@@ -1492,7 +1709,11 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each dailyPending as chore} {#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}> <button
class="chore"
onclick={() => toggle(chore)}
disabled={togglingIds.has(chore.id)}
>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1509,7 +1730,11 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each weeklyPending as chore} {#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}> <button
class="chore"
onclick={() => toggle(chore)}
disabled={togglingIds.has(chore.id)}
>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1528,7 +1753,7 @@
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
class:todo-expired={expired} class:todo-expired={expired}
onclick={() => toggle(todo)} onclick={() => toggle(todo)}
disabled={!!togglingIds || expired} disabled={togglingIds.has(todo.id) || expired}
> >
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="todo-main"> <span class="todo-main">
@@ -1556,7 +1781,7 @@
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
class:todo-expired={expired} class:todo-expired={expired}
onclick={() => toggle(todo)} onclick={() => toggle(todo)}
disabled={!!togglingIds || expired} disabled={togglingIds.has(todo.id) || expired}
> >
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="todo-main"> <span class="todo-main">
@@ -1581,6 +1806,9 @@
{/if} {/if}
{/each} {/each}
{/if} {/if}
<button class="add-todo-cta" onclick={() => (showTodoModal = true)}>
<span aria-hidden="true">+</span> Add todo
</button>
</div> </div>
<div class="column col-done"> <div class="column col-done">
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2> <h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
@@ -1590,7 +1818,15 @@
{#each completedToday as c} {#each completedToday as c}
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)} {@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
{#if chore} {#if chore}
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}> <!-- Yesterday view is mark-only: un-doing a backdated
completion would toggle against this week's window
and create confusing duplicates, so undo stays on today. -->
<button
class="chore done"
onclick={() => !inYesterday && toggle(chore)}
disabled={togglingIds.has(chore.id) || inYesterday}
title={inYesterday ? 'Go back to today to undo' : undefined}
>
<span class="checkbox">{@html checkCircleIcon}</span> <span class="checkbox">{@html checkCircleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
{#if chore.type === 'emoji'} {#if chore.type === 'emoji'}
@@ -1607,6 +1843,45 @@
{/if} {/if}
<!-- WALLET / CLAIMS --> <!-- WALLET / CLAIMS -->
{#if showTodoModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-overlay"
onclick={() => !todoSaving && (showTodoModal = false)}
onkeydown={(e) => e.key === 'Escape' && !todoSaving && (showTodoModal = false)}
>
<div class="modal-card" onclick={(e) => e.stopPropagation()}>
<h3>🎯 New todo</h3>
<p class="modal-sub">Something you want to get done this week? Add it here.</p>
<input
class="modal-input"
type="text"
maxlength="80"
placeholder="e.g. Tidy my desk"
bind:value={newTodoName}
disabled={todoSaving}
autofocus
onkeydown={(e) => e.key === 'Enter' && submitTodo()}
/>
{#if todoError}
<p class="modal-error">{todoError}</p>
{/if}
<div class="modal-actions">
<button
class="modal-btn modal-cancel"
onclick={() => (showTodoModal = false)}
disabled={todoSaving}>Cancel</button
>
<button
class="modal-btn modal-add"
onclick={submitTodo}
disabled={!newTodoName.trim() || todoSaving}
>{todoSaving ? 'Adding…' : 'Add todo'}</button
>
</div>
</div>
</div>
{/if}
<div class="wallet"> <div class="wallet">
<div class="wallet-top"> <div class="wallet-top">
<span class="wallet-lbl">{@html walletIcon} Wallet</span> <span class="wallet-lbl">{@html walletIcon} Wallet</span>
@@ -2104,6 +2379,96 @@
margin-bottom: 1rem; margin-bottom: 1rem;
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);
position: relative;
overflow: hidden;
}
/* Week-activity line effect behind the hero content — no axes, just a
blurred white trace with dots. Pure decoration, ignored by AT. */
.hero-bg {
position: absolute;
inset: auto 0 0 0;
width: 100%;
height: 120px;
opacity: 0.45;
filter: blur(5px);
pointer-events: none;
}
.hero-top,
.hero-progress,
.hero-meta,
.hero-nav {
position: relative;
z-index: 1;
}
.hero-now {
/* Same full-width centered slot as .hero-days so the hero keeps its height */
flex: 1 1 100%;
margin-top: 0.25rem;
min-height: 2.6rem;
align-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.hero-now span {
display: inline-block;
background: rgba(255, 255, 255, 0.92);
color: #4338ca;
border-radius: 999px;
padding: 0.45rem 1.1rem;
font-size: 0.85rem;
font-weight: 800;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.hero-now:hover span {
background: #fff;
}
.hero-now.hero-yesterday span {
background: rgba(255, 255, 255, 0.22);
color: #fff;
border: 1px dashed rgba(255, 255, 255, 0.7);
box-shadow: none;
font-weight: 700;
}
.hero-now.hero-yesterday:hover span {
background: rgba(255, 255, 255, 0.32);
}
.yesterday-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
border-radius: 10px;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
margin-bottom: 0.75rem;
}
.yesterday-back {
background: none;
border: none;
color: #b45309;
font-weight: 800;
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
}
.yesterday-back:hover {
text-decoration: underline;
}
.yesterday-tag {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #b45309;
background: #fef3c7;
border-radius: 999px;
padding: 0.1rem 0.45rem;
flex-shrink: 0;
} }
.hero-top { .hero-top {
display: flex; display: flex;
@@ -2227,6 +2592,16 @@
margin-bottom: 0.9rem; margin-bottom: 0.9rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Fixed slot for the pills / no-bonus line so week navigation never
moves the hero when the bonus content changes height. Fits two rows
of pills; more than that still grows gracefully. */
.hero-bonus-slot {
flex: 1 1 auto;
display: flex;
align-items: center;
min-height: 3.4rem;
align-content: center;
}
.hero-pills { .hero-pills {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
@@ -2241,10 +2616,6 @@
color: #fff; color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.5);
} }
.hero-null {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
}
.hero-days { .hero-days {
font-size: 1rem; font-size: 1rem;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
@@ -2252,6 +2623,9 @@
flex: 1 1 100%; flex: 1 1 100%;
text-align: center; text-align: center;
margin-top: 0.25rem; margin-top: 0.25rem;
/* Fixed slot so swapping in the back-to-this-week CTA never moves the hero */
min-height: 2.6rem;
align-content: center;
} }
.hero-days b { .hero-days b {
font-size: 1.5rem; font-size: 1.5rem;
@@ -2710,6 +3084,93 @@
padding: 1px 5px; padding: 1px 5px;
flex-shrink: 0; flex-shrink: 0;
} }
.add-todo-cta {
width: 100%;
margin-top: 0.5rem;
padding: 0.55rem;
border: 1.5px dashed #c7d2fe;
border-radius: 10px;
background: #eef2ff;
color: #4338ca;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.add-todo-cta:hover {
background: #e0e7ff;
border-color: #818cf8;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.modal-card {
background: #fff;
border-radius: 14px;
padding: 1.25rem;
width: 100%;
max-width: 380px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
.modal-card h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.modal-sub {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: #6b7280;
}
.modal-input {
width: 100%;
box-sizing: border-box;
border: 1.5px solid #d1d5db;
border-radius: 10px;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
}
.modal-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px #e0e7ff;
}
.modal-error {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: #dc2626;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.modal-btn {
border: none;
border-radius: 10px;
padding: 0.55rem 1rem;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: default;
}
.modal-cancel {
background: #f3f4f6;
color: #374151;
}
.modal-add {
background: #4338ca;
color: #fff;
}
.todo-main { .todo-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2951,12 +3412,14 @@
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1rem 1.2rem; padding: 1rem 1.2rem;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(135deg, #4f46e5, #7c3aed); background: linear-gradient(135deg, #f59e0b, #fbbf24);
color: #fff; color: #451a03;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35); box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
} }
.pd-icon { .pd-icon {
font-size: 1.8rem; display: flex;
align-items: center;
flex-shrink: 0;
} }
.pd-body { .pd-body {
display: flex; display: flex;
@@ -2981,6 +3444,91 @@
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.85; opacity: 0.85;
} }
/* ── Payday party: nyan-style colour-cycling celebration ── */
.payday-party {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
padding: 1rem 1.2rem;
border-radius: 14px;
color: #fff;
cursor: pointer;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
background: linear-gradient(
90deg,
#ef4444,
#f97316,
#eab308,
#22c55e,
#3b82f6,
#a855f7,
#ec4899,
#ef4444
);
background-size: 300% 100%;
animation:
nyan-slide 3s linear infinite,
party-pop 0.45s ease-out;
box-shadow: 0 8px 24px rgba(168, 85, 247, 0.45);
}
@keyframes nyan-slide {
to {
background-position: 300% 0;
}
}
@keyframes party-pop {
from {
transform: scale(0.85);
}
to {
transform: scale(1);
}
}
.party-cat {
font-size: 2.6rem;
animation: party-bounce 0.55s ease-in-out infinite alternate;
}
@keyframes party-bounce {
from {
transform: translateY(-4px) rotate(-10deg);
}
to {
transform: translateY(4px) rotate(10deg);
}
}
.party-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.party-title {
margin: 0;
font-size: 1.5rem;
font-weight: 900;
letter-spacing: 0.1em;
animation: party-wiggle 1s ease-in-out infinite;
}
@keyframes party-wiggle {
0%,
100% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
}
.party-sub {
margin: 0;
font-size: 0.85rem;
opacity: 0.95;
}
.party-stars {
margin-left: auto;
font-size: 1.4rem;
animation: party-bounce 0.55s ease-in-out infinite alternate-reverse;
}
/* Mobile: declared last so it wins over the base .goal-mini (.kanban) /* Mobile: declared last so it wins over the base .goal-mini (.kanban)
rules (same specificity, source order decides). */ rules (same specificity, source order decides). */
@media (max-width: 639px) { @media (max-width: 639px) {
@@ -116,7 +116,8 @@ export const actions = {
const startDate = fd.get('startDate') as string; const startDate = fd.get('startDate') as string;
if (type) data.type = type; if (type) data.type = type;
if (type === 'emoji') { if (type === 'emoji') {
data.value = 0; // 1, not 0: PB treats 0 as blank on the required `value` field.
data.value = 1;
data.emoji = (fd.get('emoji') as string) || '🎉'; data.emoji = (fd.get('emoji') as string) || '🎉';
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0; } else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
data.customName = customName || undefined; data.customName = customName || undefined;
@@ -160,7 +161,7 @@ export const actions = {
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
const name = fd.get('name') as string; const name = fd.get('name') as string;
const type = fd.get('type') as string; const type = fd.get('type') as string;
const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0); const value = type === 'emoji' ? 1 : (parseFloat(fd.get('value') as string) || 0);
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : ''; const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
const todoStart = fd.get('todoStart') as string; const todoStart = fd.get('todoStart') as string;
const todoCompleteBy = fd.get('todoCompleteBy') as string; const todoCompleteBy = fd.get('todoCompleteBy') as string;
@@ -242,10 +242,11 @@
showTodoModal = true; showTodoModal = true;
} }
function openCreateChore() { function openCreateChore(memberId = '') {
assignModal = { t: null as any, memberId: members[0]?.id || '' }; const target = memberId || members[0]?.id || '';
assignModal = { t: null as any, memberId: target };
assignStep = 1; assignStep = 1;
assignTarget = 'member'; assignTarget = target ? 'member' : 'shared';
assignName = ''; assignName = '';
assignDescription = ''; assignDescription = '';
assignColor = ''; assignColor = '';
@@ -390,12 +391,14 @@
{/if} {/if}
<CardGrid stackTablet> <CardGrid stackTablet>
<!-- Templates Section (at top, like bonuses) --> <!-- Templates Section (at top, like bonuses). Desktop only — dragging
<Card cols={3}> needs pointer precision; below desktop each member card gets its own
"Create a chore" button instead. -->
<Card cols={3} class="chores-templates-card">
<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>
<button class="add-inline" onclick={openCreateChore}>+ Create a chore</button> <button class="add-inline" onclick={() => openCreateChore()}>+ Create a chore</button>
</div> </div>
<div class="template-list"> <div class="template-list">
{#each templates as t} {#each templates as t}
@@ -607,6 +610,9 @@
{#if assignedForMember(m.id).length === 0} {#if assignedForMember(m.id).length === 0}
<p class="empty">Drop a chore here</p> <p class="empty">Drop a chore here</p>
{/if} {/if}
<button class="add-inline accordion-add" onclick={() => openCreateChore(m.id)}
>+ Create a chore</button
>
</div> </div>
{/if} {/if}
</div> </div>
@@ -690,6 +696,9 @@
{:else} {:else}
<p class="empty">Drop a chore here</p> <p class="empty">Drop a chore here</p>
{/each} {/each}
<button class="add-inline accordion-add" onclick={() => openCreateChore('')}
>+ Create a chore</button
>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1193,6 +1202,12 @@
.template-list .card.template { .template-list .card.template {
width: 100%; width: 100%;
} }
/* Below desktop there's no drag-and-drop — hide the templates strip
(each member/shared card carries its own Create button instead).
:global because the class lives on the Card component's root. */
:global(.chores-templates-card) {
display: none;
}
} }
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */ /* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban { .kanban {
@@ -1266,6 +1281,7 @@
.add-inline:hover { .add-inline:hover {
border-color: #6366f1; border-color: #6366f1;
background: #eef2ff; background: #eef2ff;
color: #4338ca;
} }
.card { .card {
@@ -169,7 +169,9 @@
weeknav={{ weeknav={{
current: isCurrentWeek ? 'This week' : formatShortDate(weekStart), current: isCurrentWeek ? 'This week' : formatShortDate(weekStart),
onPrev: () => navigateWeek(-1), onPrev: () => navigateWeek(-1),
onNext: () => navigateWeek(1) onNext: () => navigateWeek(1),
onCurrent: () => (weekStart = currentWeek),
isCurrent: isCurrentWeek
}} }}
/> />
@@ -1,6 +1,7 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { servicesFor } from '$lib/server/servicesFor'; import { servicesFor } from '$lib/server/servicesFor';
import { getPlatformFlags } from '$lib/server/platform'; import { getPlatformFlags } from '$lib/server/platform';
import { hasPin } from '$lib/server/pins';
async function isDemoMode(): Promise<boolean> { async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo; return (await getPlatformFlags()).demo;
@@ -8,7 +9,20 @@ async function isDemoMode(): Promise<boolean> {
export async function load(event) { export async function load(event) {
if (await isDemoMode()) { if (await isDemoMode()) {
return { verified: false, role: '', token: '', memberId: '', famId: '', memberName: '', memberColor: '', pattern: '', themeSize: '', themeOpacity: '', email: '', demoMode: true }; return {
verified: false,
role: '',
token: '',
memberId: '',
famId: '',
memberName: '',
memberColor: '',
pattern: '',
themeSize: '',
themeOpacity: '',
email: '',
demoMode: true
};
} }
const session = event.locals.user; const session = event.locals.user;
@@ -49,7 +63,9 @@ export async function load(event) {
pattern: me.pattern || '', pattern: me.pattern || '',
themeSize: me.themeSize || '', themeSize: me.themeSize || '',
themeOpacity: me.themeOpacity || '', themeOpacity: me.themeOpacity || '',
partyEmoji: me.partyEmoji || '',
email: me.email || '', email: me.email || '',
hasPin: false,
session: true session: true
}; };
} catch { } catch {
@@ -61,7 +77,8 @@ export async function load(event) {
famId: '', famId: '',
memberName: '', memberName: '',
memberColor: '', memberColor: '',
email: '' email: '',
hasPin: false
}; };
} }
} }
@@ -70,7 +87,7 @@ export async function load(event) {
return { return {
verified: true, verified: true,
role: 'child', role: 'child',
token: event.cookies.get('pb_token') || '', token: event.locals.pbToken || '',
memberId: session.id, memberId: session.id,
famId, famId,
memberName: session.name || '', memberName: session.name || '',
@@ -78,7 +95,9 @@ export async function load(event) {
pattern: session.pattern || '', pattern: session.pattern || '',
themeSize: session.themeSize || '', themeSize: session.themeSize || '',
themeOpacity: session.themeOpacity || '', themeOpacity: session.themeOpacity || '',
partyEmoji: session.partyEmoji || '',
email: '', email: '',
hasPin: await hasPin(session.id).catch(() => false),
session: true session: true
}; };
} }
@@ -95,6 +114,7 @@ export const actions = {
const pattern = (fd.get('pattern') as string) || ''; const pattern = (fd.get('pattern') as string) || '';
const themeSize = (fd.get('themeSize') as string) || ''; const themeSize = (fd.get('themeSize') as string) || '';
const themeOpacity = (fd.get('themeOpacity') as string) || ''; const themeOpacity = (fd.get('themeOpacity') as string) || '';
const partyEmoji = String(fd.get('partyEmoji') || '').trim().slice(0, 8);
const s = servicesFor(event); const s = servicesFor(event);
const famId = session.famId; const famId = session.famId;
@@ -107,6 +127,7 @@ export const actions = {
data.pattern = pattern; data.pattern = pattern;
data.themeSize = themeSize; data.themeSize = themeSize;
data.themeOpacity = themeOpacity; data.themeOpacity = themeOpacity;
if (partyEmoji) data.partyEmoji = partyEmoji;
data.email = email || ''; data.email = email || '';
const me = await s.fam.updateProfile(famId, data); const me = await s.fam.updateProfile(famId, data);
return { return {
@@ -116,6 +137,7 @@ export const actions = {
pattern: me.pattern, pattern: me.pattern,
themeSize: me.themeSize, themeSize: me.themeSize,
themeOpacity: me.themeOpacity, themeOpacity: me.themeOpacity,
partyEmoji: me.partyEmoji,
email: me.email email: me.email
}; };
} catch (e) { } catch (e) {
@@ -131,6 +153,7 @@ export const actions = {
data.pattern = pattern; data.pattern = pattern;
data.themeSize = themeSize; data.themeSize = themeSize;
data.themeOpacity = themeOpacity; data.themeOpacity = themeOpacity;
if (partyEmoji) data.partyEmoji = partyEmoji;
const me = await s.fam.updateProfile(famId, data); const me = await s.fam.updateProfile(famId, data);
return { return {
success: true, success: true,
@@ -139,6 +162,7 @@ export const actions = {
pattern: me.pattern, pattern: me.pattern,
themeSize: me.themeSize, themeSize: me.themeSize,
themeOpacity: me.themeOpacity, themeOpacity: me.themeOpacity,
partyEmoji: me.partyEmoji,
email: '' email: ''
}; };
} catch (e) { } catch (e) {
@@ -7,6 +7,50 @@
let { data, form } = $props(); let { data, form } = $props();
let pinMsg = $state('');
async function pinRequest(action: 'set' | 'change', pin: string, currentPin = '') {
pinMsg = '';
try {
const res = await fetch('/api/pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, pin, currentPin })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
pinMsg = 'Saved — try it on the shared computer!';
await invalidateAll();
} catch (e) {
pinMsg = e instanceof Error ? e.message : 'Could not save PIN';
}
}
function setPin(e: SubmitEvent) {
const fd = new FormData(e.currentTarget as HTMLFormElement);
const pin = String(fd.get('pin') || '').trim();
const confirm = String(fd.get('confirm') || '').trim();
e.preventDefault();
if (pin !== confirm) {
pinMsg = 'PINs do not match';
return;
}
pinRequest('set', pin);
}
function changePin(e: SubmitEvent) {
const fd = new FormData(e.currentTarget as HTMLFormElement);
const current = String(fd.get('current') || '').trim();
const pin = String(fd.get('pin') || '').trim();
const confirm = String(fd.get('confirm') || '').trim();
e.preventDefault();
if (pin !== confirm) {
pinMsg = 'PINs do not match';
return;
}
pinRequest('change', pin, current);
}
let memberName = $state(data.memberName || ''); let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1'); let memberColor = $state(data.memberColor || '#6366f1');
let memberEmail = $state(data.email || ''); let memberEmail = $state(data.email || '');
@@ -26,6 +70,14 @@
'#8b5cf6', '#8b5cf6',
'#14b8a6' '#14b8a6'
]; ];
// Payday-party mascot — the emoji that leads the child's payday celebration.
let memberEmoji = $state(data.partyEmoji || '🐖');
const partyEmojis = [
'🐖', '🐱', '🐶', '🦄', '🐸', '🐵', '🦊', '🐼',
'🦁', '🐯', '🐰', '🐨', '🐷', '🐮', '🦖', '🐙',
'🚀', '🌈', '⭐', '🎮', '⚽', '🍕', '🍦', '🎉'
];
</script> </script>
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero /> <ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero />
@@ -101,6 +153,28 @@
<input type="hidden" name="color" value={memberColor} /> <input type="hidden" name="color" value={memberColor} />
</div> </div>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Party emoji — leads your payday celebration 🎉
</label>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap">
{#each partyEmojis as e}
<button
type="button"
onclick={() => (memberEmoji = e)}
aria-label="Pick {e}"
style="font-size:1.5rem;line-height:1;padding:0.25rem;border-radius:8px;border:{e ===
memberEmoji
? '2px solid #111827'
: '2px solid transparent'};background:{e === memberEmoji
? '#f3f4f6'
: 'transparent'};cursor:pointer"
>{e}</button>
{/each}
</div>
<input type="hidden" name="partyEmoji" value={memberEmoji} />
</div>
{#if data.role === 'parent'} {#if data.role === 'parent'}
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem"> <label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
@@ -160,6 +234,70 @@
</Card> </Card>
</form> </form>
{#if data.role === 'child'}
<Card cols={1} title="My PIN">
{#if data.hasPin}
<p class="theme-hint">
Your 3-digit PIN switches you back to your chores on a shared computer. Forgot it? Your
parent can read it out — Settings → Invites → PIN.
</p>
<form class="pin-form" onsubmit={changePin}>
<input
name="current"
inputmode="numeric"
maxlength="3"
required
placeholder="Current PIN"
class="pin-input"
/>
<input
name="pin"
inputmode="numeric"
maxlength="3"
required
placeholder="New PIN"
class="pin-input"
/>
<input
name="confirm"
inputmode="numeric"
maxlength="3"
required
placeholder="Confirm"
class="pin-input"
/>
<Button type="submit" size="sm">Change PIN</Button>
</form>
{:else}
<p class="theme-hint">
Set a 3-digit PIN so you can switch back to your chores quickly on a shared computer.
</p>
<form class="pin-form" onsubmit={setPin}>
<input
name="pin"
inputmode="numeric"
maxlength="3"
required
placeholder="Your PIN"
class="pin-input"
/>
<input
name="confirm"
inputmode="numeric"
maxlength="3"
required
placeholder="Confirm"
class="pin-input"
/>
<Button type="submit" size="sm">Set PIN</Button>
</form>
{/if}
{#if pinMsg}
<p class="pin-msg">{pinMsg}</p>
{/if}
</Card>
{/if}
<Card cols={1} title="Account"> <Card cols={1} title="Account">
<p style="font-size:0.85rem;color:#6b7280"> <p style="font-size:0.85rem;color:#6b7280">
To change your device or sign in on a new device, use your invite link. To change your device or sign in on a new device, use your invite link.
@@ -182,4 +320,25 @@
color: #6b7280; color: #6b7280;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.pin-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.pin-input {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
text-align: center;
letter-spacing: 0.3em;
}
.pin-msg {
font-size: 0.85rem;
font-weight: 600;
color: #059669;
}
</style> </style>
@@ -488,7 +488,7 @@
{/if} {/if}
{/each} {/each}
<div class="col-card-foot"> <div class="col-card-foot">
<span class="click-edit">Click to edit</span> <span class="click-edit">Mark as complete</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -565,7 +565,7 @@
</div> </div>
{/if} {/if}
<div class="col-card-foot"> <div class="col-card-foot">
<span class="click-edit">Click to edit</span> <span class="click-edit">Mark as complete</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -644,7 +644,7 @@
{/if} {/if}
{/if} {/if}
<div class="col-card-foot"> <div class="col-card-foot">
<span class="click-edit">Click to edit</span> <span class="click-edit">Mark as complete</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -8,6 +8,7 @@ import { sendParentInviteEmail } from '$lib/server/email';
import { slugify, handle, famUsername } from '@shared/slugify'; import { slugify, handle, famUsername } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; import { applyAccessCode } from '$lib/server/access';
import { getPlatformFlags } from '$lib/server/platform'; import { getPlatformFlags } from '$lib/server/platform';
import { getPin, resetPin as resetChildPin } from '$lib/server/pins';
import { import {
createBillingPortalSession, createBillingPortalSession,
cancelSubscriptionAtPeriodEnd, cancelSubscriptionAtPeriodEnd,
@@ -26,7 +27,15 @@ async function isDemoMode(): Promise<boolean> {
export async function load(event: RequestEvent) { export async function load(event: RequestEvent) {
if (await isDemoMode()) { if (await isDemoMode()) {
return { members: [], fam: null, seasons: [], accessCode: null, subStatus: null, demoMode: true }; return {
members: [],
fam: null,
seasons: [],
accessCode: null,
subStatus: null,
demoMode: true,
lockMins: 10
};
} }
const famId = famIdOf(event); const famId = famIdOf(event);
const pb = pbUser(event); const pb = pbUser(event);
@@ -48,7 +57,13 @@ export async function load(event: RequestEvent) {
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) { if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null); subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null);
} }
return { members, fam, seasons, accessCode, subStatus }; // Shared-device idle lock (0 = off). Missing row → default 10 min.
const settingsRow = await pbAdmin
.getList('settings', `famId='${famId}'`)
.then((rows: any[]) => rows?.[0] || null)
.catch(() => null);
const lockMins = settingsRow && settingsRow.lockMins != null ? Number(settingsRow.lockMins) : 10;
return { members, fam, seasons, accessCode, subStatus, lockMins };
} }
export const actions = { export const actions = {
@@ -159,6 +174,59 @@ export const actions = {
return { ok: true }; return { ok: true };
}, },
// Parent reads a child's shared-device PIN (children forget theirs often).
revealPin: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = (fd.get('id') || '').toString();
if (!id) return { error: 'Member ID required' };
try {
const member = await pbAdmin.getOne('users', id).catch(() => null);
if (!member || member.famId !== famId) return { error: 'No such member' };
const pin = await getPin(id);
if (!pin) return { ok: true, pin: '', name: member.name || '', unset: true };
return { ok: true, pin, name: member.name || '' };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to reveal PIN' };
}
},
// Parent hands out a fresh PIN when a child forgets theirs.
resetPin: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = (fd.get('id') || '').toString();
if (!id) return { error: 'Member ID required' };
try {
const member = await pbAdmin.getOne('users', id).catch(() => null);
if (!member || member.famId !== famId) return { error: 'No such member' };
const pin = await resetChildPin(famId, id);
return { ok: true, pin, name: member.name || '' };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to reset PIN' };
}
},
// Shared-computer idle lock: off / 2 / 10 minutes before returning to the
// profile picker (children only). Missing settings row → created.
updateLock: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const mins = parseInt((fd.get('mins') || '').toString(), 10);
if (![0, 2, 10].includes(mins)) return { error: 'Invalid value' };
const pb = await createSuperClient();
let row = await pb
.collection('settings')
.getFirstListItem(`famId='${famId}'`)
.catch(() => null);
if (row) {
await pb.collection('settings').update(row.id, { lockMins: mins });
} else {
await pb.collection('settings').create({ famId, lockMins: mins });
}
return { ok: true, lockMins: mins };
},
updatePayday: async (event: RequestEvent) => { updatePayday: async (event: RequestEvent) => {
const famId = famIdOf(event); const famId = famIdOf(event);
const fd = await event.request.formData(); const fd = await event.request.formData();
@@ -9,6 +9,7 @@
CardGrid, CardGrid,
Card, Card,
Button, Button,
SaveButton,
Accordion, Accordion,
AccordionItem, AccordionItem,
NoticeDialog NoticeDialog
@@ -80,15 +81,34 @@
detectedTz = ''; detectedTz = '';
} }
} }
// 'auto' resolves to the SERVER's timezone (UTC in prod), not the
// family's — so during BST everything lands an hour late. Pre-select
// the admin's own zone so saving stores a real IANA zone (DST-aware).
if ((timezone === 'auto' || !timezone) && detectedTz && timezoneOptions.includes(detectedTz)) {
timezone = detectedTz;
}
}); });
let qrDataUrl = $state(''); let qrDataUrl = $state('');
let copied = $state(false); let copied = $state(false);
// Save-button success feedback: each save form holds a SaveButton ref and
// flashes it green for 4s from its use:enhance callback on success.
type Flasher = { flash: () => void } | null;
let renameBtn: Flasher = $state(null);
let paydayBtn: Flasher = $state(null);
let seasonBtn: Flasher = $state(null);
let lockBtn: Flasher = $state(null);
let addMemberBtn: Flasher = $state(null);
let inviteBtn: Flasher = $state(null);
let applyCodeBtn: Flasher = $state(null);
let parentInviteName = $state(''); let parentInviteName = $state('');
let parentInviteEmail = $state(''); let parentInviteEmail = $state('');
let members = $derived(famStore.initialized ? famStore.members : data.members || []); let members = $derived(famStore.initialized ? famStore.members : data.members || []);
let deletingSeason = $state<any>(null); let deletingSeason = $state<any>(null);
let issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(null); let issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let pinModal = $state<{ id: string; name: string; pin: string; unset?: boolean } | null>(null);
let lockMins = $state(String(data.lockMins ?? 10));
let seasonColor = $state('#6366f1'); let seasonColor = $state('#6366f1');
const seasonColors = [ const seasonColors = [
'#6366f1', '#6366f1',
@@ -145,6 +165,7 @@
if (result.type === 'success') { if (result.type === 'success') {
const d = result.data as { ok?: boolean; error?: string } | undefined; const d = result.data as { ok?: boolean; error?: string } | undefined;
if (d?.ok) { if (d?.ok) {
inviteBtn?.flash();
notices.success( notices.success(
'Invite sent', 'Invite sent',
`Email sent to ${parentInviteEmail} with a join link and code.` `Email sent to ${parentInviteEmail} with a join link and code.`
@@ -201,7 +222,16 @@
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep <code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working. working.
</p> </p>
<form method="POST" action="?/renameFam" use:enhance> <form
method="POST"
action="?/renameFam"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') renameBtn?.flash();
};
}}
>
<label class="field-label" for="fam-name">Display name</label> <label class="field-label" for="fam-name">Display name</label>
<input <input
id="fam-name" id="fam-name"
@@ -210,7 +240,7 @@
placeholder={fam?.name || 'Family name'} placeholder={fam?.name || 'Family name'}
required required
/> />
<Button type="submit" size="sm">Rename</Button> <SaveButton bind:this={renameBtn} label="Rename" />
</form> </form>
{#if fam?.slug} {#if fam?.slug}
<p class="hint slug-line"> <p class="hint slug-line">
@@ -226,6 +256,7 @@
use:enhance={() => { use:enhance={() => {
return async ({ result }) => { return async ({ result }) => {
if (result.type === 'error') alert(result.error || 'Failed to update payday'); if (result.type === 'error') alert(result.error || 'Failed to update payday');
else if (result.type === 'success') paydayBtn?.flash();
}; };
}} }}
class="payday-form" class="payday-form"
@@ -257,11 +288,12 @@
{/each} {/each}
</select> </select>
</div> </div>
<Button type="submit" size="sm">Set payday</Button> <SaveButton bind:this={paydayBtn} label="Set payday" />
</form> </form>
<p class="hint"> <p class="hint">
Payday: the week starts on this day and weekly earnings are settled at this time. Auto Payday: the week starts on this day and weekly earnings are settled at this time. Pick
timezone follows each device. your city so payday lands at the right local time — Auto uses the server's timezone
(UTC), which is an hour out during daylight saving.
</p> </p>
</Card> </Card>
<Card title="Seasons" cols={1}> <Card title="Seasons" cols={1}>
@@ -269,7 +301,17 @@
Group chores into seasons. Tick a season on to make it available for assignment; untick to Group chores into seasons. Tick a season on to make it available for assignment; untick to
disable it. disable it.
</p> </p>
<form method="POST" action="?/createSeason" use:enhance class="season-form"> <form
method="POST"
action="?/createSeason"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') seasonBtn?.flash();
};
}}
class="season-form"
>
<label class="field-label" for="season-name">New season</label> <label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required /> <input id="season-name" name="name" placeholder="Season name" required />
<div class="season-color-row"> <div class="season-color-row">
@@ -288,7 +330,7 @@
</div> </div>
<input type="hidden" name="color" value={seasonColor} /> <input type="hidden" name="color" value={seasonColor} />
</div> </div>
<Button type="submit" size="sm">Add</Button> <SaveButton bind:this={seasonBtn} label="Add" />
</form> </form>
<ul class="season-list"> <ul class="season-list">
@@ -363,6 +405,34 @@
</div> </div>
</div> </div>
{/if} {/if}
<Card title="Shared computer" cols={1}>
<p class="hint">
If kids share this computer, FamDone returns to the profile picker after a break so nobody
lands on the wrong chores. Children pick a 3-digit PIN when they join.
</p>
<form
method="POST"
action="?/updateLock"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) {
lockMins = String(d.lockMins);
lockBtn?.flash();
} else if (d?.error) alert(d.error);
};
}}
>
<select name="mins" bind:value={lockMins}>
<option value="0">Off — keep the last profile</option>
<option value="2">After 2 minutes of no use</option>
<option value="10">After 10 minutes of no use</option>
</select>
<SaveButton bind:this={lockBtn} label="Save" />
</form>
</Card>
</CardGrid> </CardGrid>
</AccordionItem> </AccordionItem>
@@ -370,7 +440,16 @@
<CardGrid> <CardGrid>
<Card title="New member" cols={1}> <Card title="New member" cols={1}>
<p class="hint">Add a child. They'll pick their own colour after joining.</p> <p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance> <form
method="POST"
action="?/addMember"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') addMemberBtn?.flash();
};
}}
>
<label class="field-label" for="new-child">New child</label> <label class="field-label" for="new-child">New child</label>
<input <input
id="new-child" id="new-child"
@@ -379,7 +458,7 @@
placeholder="Child name" placeholder="Child name"
required required
/> />
<Button type="submit" size="sm">Add child</Button> <SaveButton bind:this={addMemberBtn} label="Add child" />
</form> </form>
</Card> </Card>
@@ -478,6 +557,32 @@
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span> <span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span> </span>
</span> </span>
<form
method="POST"
action="?/revealPin"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) {
pinModal = {
id: String(formData.get('id') || ''),
name: d.name || m.name,
pin: d.pin || '',
unset: !!d.unset
};
} else if (d?.error) {
alert(d.error);
}
};
}}
class="inline"
>
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="ghost" size="sm" title="Shared-computer PIN"
>PIN</Button
>
</form>
<form method="POST" action="?/deleteMember" use:enhance class="inline"> <form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} /> <input type="hidden" name="id" value={m.id} />
<Button <Button
@@ -492,6 +597,58 @@
</ul> </ul>
</Card> </Card>
{#if pinModal}
<div class="overlay" onclick={() => (pinModal = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>{pinModal.name}'s PIN</h3>
{#if pinModal.unset}
<p class="hint">
No PIN set yet. Set one so {pinModal.name} can pick their profile on a shared computer.
</p>
<form
method="POST"
action="?/resetPin"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false };
else if (d?.error) alert(d.error);
};
}}
>
<input type="hidden" name="id" value={pinModal.id} />
<Button type="submit" size="sm" variant="primary">Set a new PIN</Button>
</form>
{:else}
<p class="code-display">{pinModal.pin}</p>
<p class="hint">
Read it out if they've forgotten. They can change it themselves in Preferences.
</p>
<form
method="POST"
action="?/resetPin"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false };
else if (d?.error) alert(d.error);
};
}}
class="inline"
>
<input type="hidden" name="id" value={pinModal.id} />
<Button type="submit" size="sm" variant="secondary">Give a new PIN</Button>
</form>
{/if}
<div class="modal-actions">
<button type="button" onclick={() => (pinModal = null)}>Close</button>
</div>
</div>
</div>
{/if}
<Card title="Invite Parent" cols={1}> <Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p> <p class="hint">Send an email invitation for another parent to join as an admin.</p>
<form <form
@@ -518,7 +675,7 @@
placeholder="parent@example.com" placeholder="parent@example.com"
required required
/> />
<Button type="submit" size="sm">Send invite</Button> <SaveButton bind:this={inviteBtn} label="Send invite" />
</form> </form>
<p class="hint">They'll receive a link with a code to set up their own password.</p> <p class="hint">They'll receive a link with a code to set up their own password.</p>
</Card> </Card>
@@ -591,6 +748,7 @@
const d = result.data as { error?: string; ok?: boolean }; const d = result.data as { error?: string; ok?: boolean };
accessMsg = d.error || 'Code applied — access enabled.'; accessMsg = d.error || 'Code applied — access enabled.';
accessCodeInput = ''; accessCodeInput = '';
if (d.ok && !d.error) applyCodeBtn?.flash();
} }
await update(); await update();
}; };
@@ -602,7 +760,7 @@
placeholder="Enter access code" placeholder="Enter access code"
autocomplete="off" autocomplete="off"
/> />
<Button type="submit" size="sm" variant="primary">Apply code</Button> <SaveButton bind:this={applyCodeBtn} label="Apply code" />
</form> </form>
{/if} {/if}
{#if accessMsg} {#if accessMsg}
@@ -1162,6 +1320,8 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@media (max-width: 639px) { @media (max-width: 639px) {
.code-display { font-size: 1.5em; } .code-display {
font-size: 1.5em;
}
} }
</style> </style>
+14 -9
View File
@@ -1,7 +1,12 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp'; import { redeemOtp } from '$lib/server/member-otp';
import { handle } from '@shared/slugify'; import { handle } from '@shared/slugify';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; import {
clearLegacyCookies,
setChildSessionCookie,
setActiveChild,
scanChildSessions
} from '$lib/server/session';
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
@@ -14,11 +19,14 @@ export const actions = {
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp }); if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp });
try { try {
// redeemOtp derives the username from the handle internally; pass the // Children join onto a shared-device session (pb_token_<id> +
// raw name so it resolves the same {famSlug}:{handle} identity. // pb_active) so siblings' sessions on this computer survive.
const token = await redeemOtp({ famSlug: fam, username: name, otp }); const { token, userId } = await redeemOtp({ famSlug: fam, username: name, otp });
clearLegacyCookies(event.cookies); clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token); setChildSessionCookie(event.cookies, userId, token);
setActiveChild(event.cookies, userId);
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
return { joined: true, famSlug: fam, username: handle(name), hasOtherKids };
} catch (e) { } catch (e) {
return fail(400, { return fail(400, {
error: e instanceof Error ? e.message : 'Join failed', error: e instanceof Error ? e.message : 'Join failed',
@@ -26,8 +34,5 @@ export const actions = {
otp otp
}); });
} }
const handleName = handle(name);
throw redirect(303, `/${fam}/${encodeURIComponent(handleName)}`);
} }
}; };
+59 -43
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
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, JoinPinFlow } from '$lib/components';
import { homeIcon } from '$lib/components/icons'; import { homeIcon } from '$lib/components/icons';
const famSlug = page.params.fam; const famSlug = page.params.fam;
let name = $state(''); let name = $state('');
let otp = $state(page.url.searchParams.get('code') || ''); let otp = $state(page.url.searchParams.get('code') || '');
let joined = $state<{ username: string; hasOtherKids: boolean } | null>(null);
let { form } = $props(); let { form } = $props();
</script> </script>
@@ -19,51 +20,66 @@
> >
<span class="home-badge">{@html homeIcon}</span> <span class="home-badge">{@html homeIcon}</span>
</div> </div>
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your name and the code your parent gave you to get started.
</p>
<form {#if joined}
class="mt-6 flex flex-col gap-3" <JoinPinFlow
method="POST" targetUrl={`/${famSlug}/${encodeURIComponent(joined.username)}`}
use:enhance={() => { force={joined.hasOtherKids}
return async ({ result, update }) => {
if (result.type === 'failure') {
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/> />
<input {:else}
type="text" <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
name="otp" <p class="mt-1 text-sm text-slate-500">
bind:value={otp} Enter your name and the code your parent gave you to get started.
inputmode="numeric" </p>
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400"> <form
Code is valid for 20 minutes. Ask your parent for a new one if it expires. class="mt-6 flex flex-col gap-3"
</p> method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
await update();
return;
}
const d = (result as any).data;
if (d?.joined) {
joined = { username: d.username, hasOtherKids: !!d.hasOtherKids };
return;
}
await update();
};
}}
>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
{/if}
</section> </section>
</main> </main>
@@ -1,7 +1,14 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp'; import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp';
import { createSuperClient } from '$lib/server/pocketbase'; import { createSuperClient } from '$lib/server/pocketbase';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; import {
setSessionCookie,
clearLegacyCookies,
setChildSessionCookie,
setActiveChild,
scanChildSessions,
clearActiveChild
} from '$lib/server/session';
import { famUsername, handle } from '@shared/slugify'; import { famUsername, handle } from '@shared/slugify';
export async function load(event) { export async function load(event) {
@@ -58,15 +65,25 @@ export const actions = {
} }
} }
const token = isParent if (isParent) {
? await redeemParentOtp({ famSlug: fam, username, otp, password }) const token = await redeemParentOtp({ famSlug: fam, username, otp, password });
: await redeemOtp({ famSlug: fam, username, otp }); clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
// A parent join supersedes kid mode on a shared device.
clearActiveChild(event.cookies);
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
}
// Child: shared-device session (pb_token_<id> + pb_active) so
// siblings' sessions on this computer survive.
const { token, userId } = await redeemOtp({ famSlug: fam, username, otp });
clearLegacyCookies(event.cookies); clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token); setChildSessionCookie(event.cookies, userId, token);
setActiveChild(event.cookies, userId);
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
return { joined: true, famSlug: fam, username, hasOtherKids };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' }); return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
} }
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
} }
}; };
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
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, JoinPinFlow } from '$lib/components';
import { homeIcon } from '$lib/components/icons'; import { homeIcon } from '$lib/components/icons';
const famSlug = page.params.fam; const famSlug = page.params.fam;
@@ -9,6 +9,7 @@
let otp = $state(page.url.searchParams.get('code') || ''); let otp = $state(page.url.searchParams.get('code') || '');
let password = $state(''); let password = $state('');
let confirmPassword = $state(''); let confirmPassword = $state('');
let joined = $state<{ hasOtherKids: boolean } | null>(null);
let { data, form } = $props(); let { data, form } = $props();
const isParent = $derived(data?.isParent); const isParent = $derived(data?.isParent);
@@ -24,73 +25,88 @@
> >
<span class="home-badge">{@html homeIcon}</span> <span class="home-badge">{@html homeIcon}</span>
</div> </div>
{#if isParent} {#if joined}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1> <JoinPinFlow
<p class="mt-1 text-sm text-slate-500"> targetUrl={`/${famSlug}/${encodeURIComponent(username || '')}`}
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your force={joined.hasOtherKids}
email and create a password to join <strong>{famName}</strong> as a parent. />
</p>
{:else} {:else}
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1> {#if isParent}
<p class="mt-1 text-sm text-slate-500"> <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent gave <p class="mt-1 text-sm text-slate-500">
you to get started. Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
email and create a password to join <strong>{famName}</strong> as a parent.
</p>
{:else}
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
<p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
gave you to get started.
</p>
{/if}
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
otp = (result as any).data?.otp || '';
await update();
return;
}
const d = (result as any).data;
if (d?.joined) {
joined = { hasOtherKids: !!d.hasOtherKids };
return;
}
// Parent joins redirect server-side (303) — nothing to do here.
await update();
};
}}
>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if isParent}
<input
type="password"
name="password"
bind:value={password}
placeholder="Create a password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Confirm password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{/if}
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg"
>{isParent ? 'Set up my account' : 'Join'}</Button
>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p> </p>
{/if} {/if}
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if isParent}
<input
type="password"
name="password"
bind:value={password}
placeholder="Create a password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Confirm password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{/if}
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg"
>{isParent ? 'Set up my account' : 'Join'}</Button
>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
</section> </section>
</main> </main>
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
// The fam layout renders the shared-device picker when child sessions exist
// but none is active. With an active session this route just sends you home.
export async function load(event) {
const session = event.locals.user;
if (session) {
const fam = event.params.fam;
if (session.role === 'parent') throw redirect(303, `/${fam}`);
throw redirect(303, `/${fam}/${encodeURIComponent(session.username || '')}`);
}
return {};
}
@@ -0,0 +1,4 @@
<!-- Shared-device profile picker. The fam layout renders the picker itself in
picker mode (no active session, child sessions present) — this page only
exists so /{fam}/switch resolves as a route. -->
<div></div>
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) { export async function POST(event: RequestEvent) {
const u = event.locals.user; const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); if (!u || !event.locals.pbToken) {
console.error(`[diag] toggle 401 role=${u?.role} hasToken=${!!event.locals.pbToken}`);
throw error(401, 'Unauthorized');
}
const pb = createPbClient(event.locals.pbToken); const pb = createPbClient(event.locals.pbToken);
const body = await event.request.json().catch(() => ({})); const body = await event.request.json().catch(() => ({}));
try { try {
const s = createServices(pb, { id: u.id, role: u.role }); const s = createServices(pb, { id: u.id, role: u.role });
return json(await s.completions.toggle(u.famId, body)); const out = await s.completions.toggle(u.famId, body);
console.log(
`[diag] toggle ok member=${u.id} chore=${body.assignedChoreId} date=${body.date} completed=${(out as any)?.completed}`
);
return json(out);
} catch (e) { } catch (e) {
console.error(
`[diag] toggle 400 member=${u.id} role=${u.role} fam=${u.famId} chore=${body.assignedChoreId} date=${body.date} err=${e instanceof Error ? e.message : e}`
);
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 }); return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
} }
} }
@@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
// TEMP diagnostic for the recurring prod CHORE_GONE. Compares what the client
// claims against server truth from both angles (superuser getOne — the toggle
// path — and the user's own list read — the kanban path). Authenticated,
// own-fam only, no tokens ever logged or returned.
export async function GET(event: RequestEvent) {
const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
const assignedChoreId = event.url.searchParams.get('assignedChoreId') || '';
let suChore: any = null;
let suError = '';
if (assignedChoreId) {
try {
suChore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
} catch (e) {
suError = e instanceof Error ? e.message : String(e);
}
}
let userSeesIt: boolean | null = null;
let userListError = '';
try {
const pb = createPbClient(event.locals.pbToken);
const list = await pb
.collection('assigned_chores')
.getFullList({ filter: `famId = '${u.famId}'` });
if (assignedChoreId) userSeesIt = list.some((a: any) => a.id === assignedChoreId);
} catch (e) {
userListError = e instanceof Error ? e.message : String(e);
}
const out = {
session: { id: u.id, role: u.role, famId: u.famId },
asked: assignedChoreId,
superuser: suChore
? {
found: true,
famId: suChore.famId,
memberId: suChore.memberId,
isTodo: !!suChore.isTodo,
famMatch: suChore.famId === u.famId
}
: { found: false, error: suError },
userList: { seesIt: userSeesIt, error: userListError }
};
console.log(`[diag] debug-chore ${JSON.stringify(out)}`);
return json(out);
}
@@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import {
ACTIVE_COOKIE,
childSessionCookie,
clearChildSession,
clearActiveChild
} from '$lib/server/session';
// Remove ONE child profile from this device (picker ✕). Device-local cookie
// surgery only — no PB writes, no session required (the picker is reachable
// with no active user).
export async function POST(event: RequestEvent) {
const body = await event.request.json().catch(() => ({}));
const { userId } = body as { userId?: string };
if (!userId) throw error(400, 'Missing userId');
if (!event.cookies.get(childSessionCookie(userId))) {
throw error(400, 'No session on this device');
}
clearChildSession(event.cookies, userId);
if (event.cookies.get(ACTIVE_COOKIE) === userId) clearActiveChild(event.cookies);
return json({ ok: true });
}
+74
View File
@@ -0,0 +1,74 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getPin, setPin, verifyPin, hasPin, PIN_RE } from '$lib/server/pins';
import { createPbClient } from '$lib/server/pocketbase';
// PIN status for the shared-device toggle reminders. Never reveals values:
// - child → whether THEIR OWN pin is set (about self only);
// - parent → per-child set/unset roster (names + booleans, no PIN values;
// actual values stay behind the settings revealPin action).
export async function GET(event: RequestEvent) {
const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
if (u.role === 'child') {
return json({ hasPin: await hasPin(u.id) });
}
if (u.role !== 'parent') throw error(403, 'Forbidden');
const pb = createPbClient(event.locals.pbToken);
const kids: any[] = await pb
.collection('users')
.getFullList({ filter: `famId = '${u.famId}' && role = 'child'` })
.catch(() => []);
const children = await Promise.all(
kids.map(async (k: any) => ({
userId: k.id,
name: k.name || '?',
hasPin: await hasPin(k.id)
}))
);
return json({ children });
}
// Child PIN management. Session-role checked here (PB rules are superuser-only
// on `pins`): only the child themselves can set/change their own PIN.
export async function POST(event: RequestEvent) {
const u = event.locals.user;
if (!u) throw error(401, 'Unauthorized');
if (u.role !== 'child') throw error(403, 'Only children use PINs');
const body = await event.request.json().catch(() => ({}));
const { action, pin, currentPin } = body as {
action?: string;
pin?: string;
currentPin?: string;
};
if (!action || !pin || !PIN_RE.test(pin)) {
throw error(400, 'PIN must be exactly 3 digits');
}
if (action === 'set') {
if (await getPin(u.id)) throw error(400, 'PIN already set');
await setPin(u.famId, u.id, pin);
return json({ ok: true });
}
// Join wizard: assign the PIN on THIS device without touching an existing
// one (a kid joining a second shared device already has a PIN — their pin
// works everywhere; nobody can silently reassign it).
if (action === 'ensure') {
if (!(await getPin(u.id))) await setPin(u.famId, u.id, pin);
return json({ ok: true });
}
if (action === 'change') {
const existing = await getPin(u.id);
if (!existing) throw error(400, 'No PIN set yet');
if (!currentPin || !(await verifyPin(u.id, currentPin))) {
throw error(401, 'Current PIN is wrong');
}
await setPin(u.famId, u.id, pin);
return json({ ok: true });
}
throw error(400, 'Unknown action');
}
@@ -0,0 +1,84 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { createPbClient, createSuperClient } from '$lib/server/pocketbase';
import { childSessionCookie, setChildSessionCookie, setActiveChild } from '$lib/server/session';
import { verifyPin } from '$lib/server/pins';
import { derivePassword } from '$lib/server/member-otp';
import { famUsername } from '@shared/slugify';
// Shared-device PIN switch. The PIN *selects* among sessions that already
// exist on this device — it is not a credential that mints anything on a
// fresh device. The target must have a pb_token_<userId> cookie; the PIN is
// verified server-side via the superuser client (children must never read
// siblings' pins), with a small per-user rate limit on the 3-digit space.
const MAX_TRIES = 5;
const LOCK_MS = 30_000;
const tries = new Map<string, { fails: number; until: number }>();
function checkRateLimit(userId: string) {
const rec = tries.get(userId);
if (rec && rec.until > Date.now()) {
throw error(429, 'Too many attempts — try again in a few seconds');
}
}
function noteFailure(userId: string) {
const rec = tries.get(userId) || { fails: 0, until: 0 };
rec.fails += 1;
if (rec.fails >= MAX_TRIES) {
rec.until = Date.now() + LOCK_MS;
rec.fails = 0;
}
tries.set(userId, rec);
}
export async function POST(event: RequestEvent) {
const body = await event.request.json().catch(() => ({}));
const { userId, pin } = body as { userId?: string; pin?: string };
if (!userId || !pin) throw error(400, 'Missing userId or pin');
if (!event.cookies.get(childSessionCookie(userId))) {
throw error(400, 'That profile has no session on this device');
}
checkRateLimit(userId);
if (!(await verifyPin(userId, String(pin)))) {
noteFailure(userId);
throw error(401, 'Wrong PIN — try again');
}
// Ensure a usable token: refresh the device cookie; if it has expired,
// re-mint via the derived password (server-side only — the child never
// knows it, and a stale session heals itself on switch).
const pb = await createSuperClient();
const user = await pb
.collection('users')
.getOne(userId)
.catch(() => null);
if (!user || user.role !== 'child') throw error(400, 'Not a child account');
let freshToken = '';
try {
const cookieToken = event.cookies.get(childSessionCookie(userId));
if (cookieToken) {
const { token } = await createPbClient(cookieToken).collection('users').authRefresh();
freshToken = token;
}
} catch {
/* expired — re-mint below */
}
if (!freshToken) {
// username = `{famSlug}:{handle}` — the server can always re-mint.
const [famSlug, handleName] = (user.username || '').split(':');
if (!famSlug || !handleName) throw error(400, 'Cannot restore session');
const auth = createPbClient();
const { token } = await auth
.collection('users')
.authWithPassword(famUsername(famSlug, handleName), derivePassword(famSlug, handleName));
freshToken = token;
}
setChildSessionCookie(event.cookies, userId, freshToken);
setActiveChild(event.cookies, userId);
return json({ ok: true });
}
+57
View File
@@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { pbAdmin } from '$lib/server/pocketbase';
import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone';
// Kids can create todos for THEMSELVES only. Writes go through the superuser
// client because `assigned_chores.createRule` is parent-only — but every
// privileged field is forced server-side: memberId = session user, emoji type
// with zero value (so nobody can mint cash/points for themselves), weekly
// frequency, this-week start/completeBy (so it expires and purges naturally).
export async function POST(event: RequestEvent) {
const u = event.locals.user;
if (!u) {
console.error('[diag] todos 401 (no session)');
throw error(401, 'Unauthorized');
}
const body = await event.request.json().catch(() => ({}));
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
if (!name) throw error(400, 'Give your todo a name');
let payday = 1;
let tz = 'UTC';
try {
const fam: any = await pbAdmin.getOne('fams', u.famId);
if (fam.payday !== undefined && fam.payday !== null) payday = Number(fam.payday);
tz = resolveTz(fam.timezone || 'auto');
} catch {
tz = resolveTz('auto');
}
const today = todayInTz(tz);
const completeBy = addDaysStr(weekStart(payday, tz), 6);
try {
const record = await pbAdmin.create('assigned_chores', {
famId: u.famId,
memberId: u.id,
frequency: 'weekly',
type: 'emoji',
// Must be 1, not 0: PB treats 0 as blank on the required `value`
// number field (400 validation_required). Value is meaningless for
// emoji todos — never summed, never displayed, never rewarded.
value: 1,
customName: name,
isTodo: true,
emoji: '🎯',
startDate: today,
completeBy
});
console.log(`[diag] todo ok member=${u.id} fam=${u.famId} todo=${record.id} name=${name}`);
return json({ record });
} catch (e) {
console.error(
`[diag] todo 400 member=${u.id} role=${u.role} fam=${u.famId} name=${name} err=${e instanceof Error ? e.message : e}`
);
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
}
}
+16 -8
View File
@@ -1,7 +1,12 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp'; import { redeemOtp } from '$lib/server/member-otp';
import { slugify, handle } from '@shared/slugify'; import { slugify, handle } from '@shared/slugify';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; import {
clearLegacyCookies,
setChildSessionCookie,
setActiveChild,
scanChildSessions
} from '$lib/server/session';
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
@@ -12,13 +17,19 @@ export const actions = {
if (!famName) return fail(400, { error: 'Enter your family name.', famName, name, otp }); if (!famName) return fail(400, { error: 'Enter your family name.', famName, name, otp });
if (!name) return fail(400, { error: 'Enter your name.', famName, name, otp }); if (!name) return fail(400, { error: 'Enter your name.', famName, name, otp });
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp }); if (!otp)
return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp });
const famSlug = slugify(famName); const famSlug = slugify(famName);
try { try {
const token = await redeemOtp({ famSlug, username: name, otp }); // Children join onto a shared-device session (pb_token_<id> +
// pb_active) so siblings' sessions on this computer survive.
const { token, userId } = await redeemOtp({ famSlug, username: name, otp });
clearLegacyCookies(event.cookies); clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token); setChildSessionCookie(event.cookies, userId, token);
setActiveChild(event.cookies, userId);
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
return { joined: true, famSlug, username: handle(name), hasOtherKids };
} catch (e) { } catch (e) {
return fail(400, { return fail(400, {
error: e instanceof Error ? e.message : 'Join failed', error: e instanceof Error ? e.message : 'Join failed',
@@ -27,8 +38,5 @@ export const actions = {
otp otp
}); });
} }
const handleName = handle(name);
throw redirect(303, `/${famSlug}/${encodeURIComponent(handleName)}`);
} }
}; };
+75 -53
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { Button } from '$lib/components'; import { Button, JoinPinFlow } from '$lib/components';
import { homeIcon } from '$lib/components/icons'; import { homeIcon } from '$lib/components/icons';
let name = $state(''); let name = $state('');
let family = $state(''); let family = $state('');
let otp = $state(''); let otp = $state('');
let joined = $state<{ famSlug: string; username: string; hasOtherKids: boolean } | null>(null);
let { form } = $props(); let { form } = $props();
</script> </script>
@@ -13,63 +14,84 @@
<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> <span class="home-badge">{@html homeIcon}</span>
</div> </div>
<h1 class="text-xl font-bold text-slate-900">Join the family</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your family name and the code your parent gave you to get started.
</p>
<form {#if joined}
class="mt-6 flex flex-col gap-3" <JoinPinFlow
method="POST" targetUrl={`/${joined.famSlug}/${encodeURIComponent(joined.username)}`}
use:enhance={() => { force={joined.hasOtherKids}
return async ({ result, update }) => {
if (result.type === 'failure') {
family = (result.data as any)?.family || '';
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="family"
bind:value={family}
placeholder="Family name"
autocomplete="organization"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/> />
<input {:else}
type="text" <h1 class="text-xl font-bold text-slate-900">Join the family</h1>
name="name" <p class="mt-1 text-sm text-slate-500">
bind:value={name} Enter your family name and the code your parent gave you to get started.
placeholder="Your name" </p>
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400"> <form
Code is valid for 20 minutes. Ask your parent for a new one if it expires. class="mt-6 flex flex-col gap-3"
</p> method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
family = (result.data as any)?.family || '';
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
await update();
return;
}
const d = (result as any).data;
if (d?.joined) {
joined = {
famSlug: d.famSlug,
username: d.username,
hasOtherKids: !!d.hasOtherKids
};
return;
}
await update();
};
}}
>
<input
type="text"
name="family"
bind:value={family}
placeholder="Family name"
autocomplete="organization"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
{/if}
</section> </section>
</main> </main>
+18 -3
View File
@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient } from '$lib/server/pocketbase';
import { setSessionCookie } from '$lib/server/session'; import { setSessionCookie, clearActiveChild } from '$lib/server/session';
import { pbAdmin } from '$lib/server/pocketbase'; import { pbAdmin } from '$lib/server/pocketbase';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
@@ -27,9 +27,24 @@ export const actions = {
} }
setSessionCookie(event.cookies, authResult.token); setSessionCookie(event.cookies, authResult.token);
// An explicit email/password login supersedes kid mode on a shared device.
clearActiveChild(event.cookies);
const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null); // Fam lookup with retries: right after a deploy/rebuild PB can blip and
const famSlug = fam?.slug || user.famId; // a single failed fetch falls back to the raw fam ID in the URL
// (/{famId}/...), which then persists via bookmarks/shortcuts.
let fam: any = null;
for (let i = 0; i < 4 && !fam; i++) {
if (i > 0) await new Promise((r) => setTimeout(r, 500 * i));
fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
}
if (!fam) {
return fail(503, {
error: 'Family data is briefly unavailable (server restarting). Please try again.',
email
});
}
const famSlug = fam.slug;
// Parents (admins) land on the fam dashboard — no username in the URL. // Parents (admins) land on the fam dashboard — no username in the URL.
if (user.role === 'parent') { if (user.role === 'parent') {
+3 -1
View File
@@ -1,9 +1,11 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session'; import { clearSessionCookie, clearLegacyCookies, clearDeviceSessions } from '$lib/server/session';
function signOut(event: { cookies: any }) { function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies); clearSessionCookie(event.cookies);
clearLegacyCookies(event.cookies); clearLegacyCookies(event.cookies);
// Shared device: also drop every child session + the active pointer.
clearDeviceSessions(event.cookies);
} }
export const actions = { export const actions = {
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.10.6" "version": "1.11.7"
} }
+123
View File
@@ -0,0 +1,123 @@
# Shared-device switching (PIN)
Design decisions for children sharing a family computer/tablet — recorded
2026-09-11. Implements: multi-session cookies, `pins` collection, profile
picker, and the idle lock.
## Model
- **The PIN is NOT a login.** It selects among sessions that already exist on
the device. A PIN alone mints nothing on a fresh device; a stolen cookie
alone selects nothing without the PIN. Threat model: sibling mischief on a
family device (devtools-level bypass is explicitly accepted — data is
family-scoped and actions are reversible).
- Per-device state lives in cookies:
- `pb_token_<userId>` — one httpOnly cookie per child with a session on
this device (the "device is logged into both accounts" property).
- `pb_active` — which child session is currently active.
- `pb_token` — unchanged single session for parents (and legacy children).
Parent login supersedes kid mode by clearing `pb_active`; a kid switch
shadows (but does not delete) a parent session; resolving order is
`pb_active` first, then `pb_token`.
- Sessions are never the security boundary: the OTP gate still guards each
child's first-ever join on a device, and switching re-mints an expired token
server-side via the derived password (never exposed to the client).
## `pins` collection
- Superuser-only rules (like `otp`); all reads/writes via server endpoints
with session-role checks. Plaintext 3-digit PIN — deliberately recoverable
so a parent can read it out (Q1: View, not reset). Child changes their own
PIN in Preferences (requires current PIN).
- Fields: `famId` (rel), `userId` (rel), `pin` (text).
## Switch flow
`POST /api/switch-user { userId, pin }`:
1. Request must carry `pb_token_<userId>` (the profile must be on this
device). No session required — the picker is reachable with no active user.
2. PIN verified server-side via superuser (children must NOT be able to read
each other's pins, so collection rules can't be the check).
3. In-memory rate limit: 5 fails → 30s lockout per userId (3-digit space).
4. Refresh the device cookie's token; if expired, re-auth with the derived
password (heals sessions on switch).
5. Set `pb_token_<userId>` + `pb_active`; client full-reloads to that child's
dashboard URL (`/famSlug/handle`).
Per-profile removal: `POST /api/device/remove { userId }` — deletes that
cookie (+ `pb_active` if it was active). No session required (device-local
cookie surgery only).
## Picker
One component (`SharedPicker`) used everywhere:
- TopNav switcher button (always visible when the device has child sessions).
- Idle-lock return (overlay).
- `/{famSlug}/switch` — standalone landing when the device has child sessions
but no active session (e.g. after per-profile logout). The fam layout
redirects unauthenticated device requests to it. Links: "Add a child with a
code" → `/{famSlug}/join`, "Parent login" → `/login`.
Every pick (including "resume" as the current kid) requires the PIN.
## Idle lock
- Default 10 min; parent-adjustable in Family Settings: `Off / 2 / 10`
(`settings.lockMins`, 0 = off; `shared-device.md` settled default 10).
- Client-side only, child sessions only (parent sessions never lock).
- **Only fires when shared-computer mode is ON for the device** (see below) —
a personal device never locks, even with lockMins set.
- `localStorage['fam_last_active']` updated on click/key/touch (throttled
10s) and force-written on `pagehide` — survives browser close, sleep and
restarts. Next launch compares elapsed time; past the timeout → picker.
Live timer (15s interval) does the same mid-session.
- Resets: join wizard and picker switch write a fresh timestamp before
navigating, so freshly-activated sessions don't instantly lock.
## Shared-computer toggle (TopNav)
- **Storage: localStorage, NOT the DB.** Shared-ness is a property of *this
browser* — a DB flag on `fams`/`settings` would force PIN mode on every
device including a parent's phone. `localStorage['fam_shared_device']`
is the source of truth; a plain `fam_shared_device=1` cookie mirrors it
so server loads see it (`data.sharedDevice`). Neither is a security
boundary — the PIN + device session cookies remain the gate.
- **Location:** monitor icon in the TopNav (fam layout children slot, next
to the profile switcher). Rendered for any role, even logged out —
whoever holds the device can mark it shared. Active state: indigo fill.
- **What it gates:**
- Profile switcher (`kid-switch`) button — only shown when shared mode
is ON and the device holds child sessions.
- Idle lock — only fires for child sessions on shared devices.
- (Server `/switch` redirect unchanged — the picker is harmless on
- non-shared devices and the server can't do better without the cookie.)
- **PIN reminders on enable** (`GET /api/pins/status`, set/unset only —
values never leave the server except via the settings `revealPin`
action):
- Parent toggling sees the roster: who still needs a PIN (→ Settings →
Members) vs who is ready (→ read the PINs out to them).
- A child toggling sees only their own state: "remember your PIN" or
- "ask a parent to set one up" (no PIN → picker shows "ask a parent").
- Client flag helpers: `lib/client/shared-device.ts`
(`isSharedDevice` / `setSharedDevice`, writes the cookie mirror too).
## Join flow (child)
After OTP redeem:
1. If the device already has other child sessions → PIN setup (required).
2. Else ask "Will this computer be shared with a sibling?" — yes → PIN setup;
no → skip (PIN can be added later in Preferences; parents can set it in
Family Settings).
3. Land on the child's dashboard.
Parents keep the existing join (set own password, single session).
## Edge cases
- Two tabs share the timestamp → the active tab keeps the other from locking.
- Tab switches don't lock (kids alt-tab constantly).
- Expired sessions: the picker still shows the profile; switching re-mints.
- No PIN set on a profile → picker shows "ask a parent to set it up".
+221 -157
View File
@@ -61,11 +61,19 @@ export function jsonField(name: string): FieldDef {
return { name, type: "json" }; return { name, type: "json" };
} }
export function select(name: string, values: string[], required = false): FieldDef { export function select(
name: string,
values: string[],
required = false,
): FieldDef {
return { name, type: "select", required, values, maxSelect: 1 }; return { name, type: "select", required, values, maxSelect: 1 };
} }
export function rel(name: string, collectionId: string, required = false): FieldDef { export function rel(
name: string,
collectionId: string,
required = false,
): FieldDef {
return { return {
name, name,
type: "relation", type: "relation",
@@ -151,65 +159,85 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
text("accessCodeId"), text("accessCodeId"),
date("accessCodeEnteredAt"), date("accessCodeEnteredAt"),
], ],
{ listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM }, {
listRule: RULE_OWN_FAM,
viewRule: RULE_OWN_FAM,
updateRule: RULE_OWN_FAM,
},
)(ids), )(ids),
}, },
{ {
name: "bonus_templates", name: "bonus_templates",
build: (ids) => build: (ids) =>
col("bonus_templates", [ col(
rel("famId", ids.fams, false), "bonus_templates",
text("name", true), [
text("description"), rel("famId", ids.fams, false),
select("target", ["individual", "competitive", "collaborative"], true), text("name", true),
select("type", ["threshold", "count", "manual"], true), text("description"),
select("thresholdType", ["points", "percent"], false), select(
select("occurrence", ["recurring", "once"], true), "target",
select("rewardType", ["points", "cash", "prize"], true), ["individual", "competitive", "collaborative"],
text("rewardValue", false), true,
number("criteriaValue"), ),
select("period", ["schedule", "daily", "weekly", "monthly"]), select("type", ["threshold", "count", "manual"], true),
bool("isPocketMoney"), select("thresholdType", ["points", "percent"], false),
bool("global"), select("occurrence", ["recurring", "once"], true),
text("icon"), select("rewardType", ["points", "cash", "prize"], true),
text("color"), text("rewardValue", false),
], { number("criteriaValue"),
listRule: "global = true || famId = @request.auth.famId", select("period", ["schedule", "daily", "weekly", "monthly"]),
viewRule: "global = true || famId = @request.auth.famId", bool("isPocketMoney"),
createRule: RULE_PARENT_WRITE, bool("global"),
updateRule: RULE_PARENT_SCOPED, text("icon"),
deleteRule: RULE_PARENT_SCOPED, text("color"),
})(ids), ],
{
listRule: "global = true || famId = @request.auth.famId",
viewRule: "global = true || famId = @request.auth.famId",
createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED,
},
)(ids),
}, },
{ {
name: "settings", name: "settings",
build: (ids) => build: (ids) =>
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], { col(
createRule: RULE_PARENT_WRITE, "settings",
updateRule: RULE_PARENT_SCOPED, [rel("famId", ids.fams, true), text("webhookUrl"), number("lockMins")],
deleteRule: RULE_PARENT_SCOPED, {
})(ids), createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED,
},
)(ids),
}, },
{ {
name: "chore_templates", name: "chore_templates",
build: (ids) => build: (ids) =>
col("chore_templates", [ col(
rel("famId", ids.fams, false), "chore_templates",
text("name", true), [
text("description"), rel("famId", ids.fams, false),
select("defaultFrequency", ["daily", "weekly"], true), text("name", true),
select("defaultType", ["points", "money"], true), text("description"),
number("defaultValue", true), select("defaultFrequency", ["daily", "weekly"], true),
bool("global"), select("defaultType", ["points", "money"], true),
text("icon"), number("defaultValue", true),
text("color"), bool("global"),
], { text("icon"),
listRule: "global = true || famId = @request.auth.famId", text("color"),
viewRule: "global = true || famId = @request.auth.famId", ],
createRule: RULE_PARENT_WRITE, {
updateRule: RULE_PARENT_SCOPED, listRule: "global = true || famId = @request.auth.famId",
deleteRule: RULE_PARENT_SCOPED, viewRule: "global = true || famId = @request.auth.famId",
})(ids), createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED,
},
)(ids),
}, },
{ {
name: "bonus_configs", name: "bonus_configs",
@@ -228,6 +256,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
rel("memberId", ids.users), rel("memberId", ids.users),
select("period", ["schedule", "daily", "weekly", "monthly"]), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true), select("status", ["active", "completed"], true),
text("completedBy"),
date("completedAt"),
text("targetChoreId"), text("targetChoreId"),
jsonField("targetChoreIds"), jsonField("targetChoreIds"),
bool("isPocketMoney"), bool("isPocketMoney"),
@@ -249,138 +279,172 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
{ {
name: "weekly_history", name: "weekly_history",
build: (ids) => build: (ids) =>
col("weekly_history", [ col(
rel("famId", ids.fams, true), "weekly_history",
rel("memberId", ids.users, true), [
date("weekStart"), rel("famId", ids.fams, true),
number("pointsEarned"), rel("memberId", ids.users, true),
number("moneyEarned"), date("weekStart"),
number("choresCompleted"), number("pointsEarned"),
number("bonusEarned"), number("moneyEarned"),
], { number("choresCompleted"),
createRule: RULE_PARENT_WRITE, number("bonusEarned"),
updateRule: RULE_PARENT_SCOPED, ],
deleteRule: RULE_PARENT_SCOPED, {
})(ids), createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED,
},
)(ids),
}, },
{ {
name: "seasons", name: "seasons",
build: (ids) => build: (ids) =>
col("seasons", [ col(
rel("famId", ids.fams, true), "seasons",
text("name", true), [
text("color"), rel("famId", ids.fams, true),
bool("active"), text("name", true),
date("autoDisable"), text("color"),
date("autoStart"), bool("active"),
], { date("autoDisable"),
createRule: RULE_PARENT_WRITE, date("autoStart"),
updateRule: RULE_PARENT_SCOPED, ],
deleteRule: RULE_PARENT_SCOPED, {
})(ids), createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED,
},
)(ids),
}, },
{ {
name: "messages", name: "messages",
build: (ids) => build: (ids) =>
col("messages", [ col(
rel("famId", ids.fams, true), "messages",
select("authorType", ["admin", "member"], true), [
text("authorId", true), rel("famId", ids.fams, true),
text("authorName", true), select("authorType", ["admin", "member"], true),
text("authorColor"), text("authorId", true),
text("content", true), text("authorName", true),
// Explicit createdAt: PB 0.39 does NOT auto-add createdAt to text("authorColor"),
// API-created collections (0.25 did). Chat filters/sorts on it. text("content", true),
date("createdAt"), // Explicit createdAt: PB 0.39 does NOT auto-add createdAt to
text("clientId"), // API-created collections (0.25 did). Chat filters/sorts on it.
], { date("createdAt"),
createRule: RULE_FAM_WRITE, text("clientId"),
updateRule: RULE_FAM_SCOPED, ],
deleteRule: RULE_FAM_SCOPED, {
listRule: RULE_FAM_READ, createRule: RULE_FAM_WRITE,
viewRule: RULE_FAM_READ, updateRule: RULE_FAM_SCOPED,
})(ids), deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
{ {
name: "chat_typing", name: "chat_typing",
build: (ids) => build: (ids) =>
col("chat_typing", [ col(
rel("famId", ids.fams, true), "chat_typing",
text("actorId", true), [
select("actorType", ["admin", "member"], true), rel("famId", ids.fams, true),
text("authorName", true), text("actorId", true),
text("authorColor"), select("actorType", ["admin", "member"], true),
bool("typing"), text("authorName", true),
], { text("authorColor"),
createRule: RULE_FAM_WRITE, bool("typing"),
updateRule: RULE_FAM_SCOPED, ],
deleteRule: RULE_FAM_SCOPED, {
listRule: RULE_FAM_READ, createRule: RULE_FAM_WRITE,
viewRule: RULE_FAM_READ, updateRule: RULE_FAM_SCOPED,
})(ids), deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
{ {
name: "rewards", name: "rewards",
build: (ids) => build: (ids) =>
col("rewards", [ col(
rel("famId", ids.fams, true), "rewards",
rel("memberId", ids.users, true), [
rel("bonusConfigId", ids.bonus_configs), rel("famId", ids.fams, true),
text("label", true), rel("memberId", ids.users, true),
number("value", true), rel("bonusConfigId", ids.bonus_configs),
select("rewardType", ["cash", "prize", "points"], true), text("label", true),
select("status", ["unclaimed", "requested", "claimed"], true), number("value", true),
select("claimable", ["immediate", "payday"], false), select("rewardType", ["cash", "prize", "points"], true),
text("settleDate"), select("status", ["unclaimed", "requested", "claimed"], true),
date("claimedAt"), select("claimable", ["immediate", "payday"], false),
date("requestedAt"), text("settleDate"),
text("date"), date("claimedAt"),
], { date("requestedAt"),
createRule: RULE_FAM_WRITE, text("date"),
updateRule: RULE_FAM_SCOPED, ],
deleteRule: RULE_FAM_SCOPED, {
})(ids), createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
{ {
name: "assigned_chores", name: "assigned_chores",
build: (ids) => build: (ids) =>
col("assigned_chores", [ col(
rel("famId", ids.fams, true), "assigned_chores",
rel("memberId", ids.users, false), // null = shared with all members [
bool("shared"), // true = initially shared (tracked for revoke) rel("famId", ids.fams, true),
rel("templateId", ids.chore_templates), rel("memberId", ids.users, false), // null = shared with all members
select("frequency", ["daily", "weekly"], true), bool("shared"), // true = initially shared (tracked for revoke)
select("type", ["points", "money", "emoji"], true), rel("templateId", ids.chore_templates),
number("value", false), select("frequency", ["daily", "weekly"], true),
text("customName"), select("type", ["points", "money", "emoji"], true),
text("description"), number("value", false),
text("icon"), text("customName"),
text("color"), text("description"),
text("emoji"), text("icon"),
jsonField("seasonIds"), text("color"),
bool("isTodo"), text("emoji"),
text("startDate"), jsonField("seasonIds"),
text("completeBy"), bool("isTodo"),
], { text("startDate"),
createRule: RULE_PARENT_WRITE, text("completeBy"),
updateRule: RULE_PARENT_SCOPED, ],
deleteRule: RULE_PARENT_SCOPED, {
})(ids), createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
{ {
name: "completions", name: "completions",
build: (ids) => build: (ids) =>
col("completions", [ col(
rel("famId", ids.fams, true), "completions",
rel("memberId", ids.users, true), [
rel("assignedChoreId", ids.assigned_chores, true), rel("famId", ids.fams, true),
date("date"), rel("memberId", ids.users, true),
date("completedAt"), rel("assignedChoreId", ids.assigned_chores, true),
text("rewardId"), date("date"),
], { date("completedAt"),
createRule: RULE_FAM_WRITE, text("rewardId"),
updateRule: RULE_FAM_SCOPED, ],
deleteRule: RULE_FAM_SCOPED, {
})(ids), createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
]; ];
+5 -1
View File
@@ -53,7 +53,11 @@ export function addDaysStr(dateStr: string, days: number): string {
export function weekStart(payday: number, tz: string): string { export function weekStart(payday: number, tz: string): string {
const today = todayInTz(tz); const today = todayInTz(tz);
const wd = weekdayInTz(new Date(), tz); const wd = weekdayInTz(new Date(), tz);
const back = (((wd - payday) % 7) + 7) % 7; const sincePayday = (((wd - payday) % 7) + 7) % 7;
// The week CLOSES on payday, so it opens the day after: a payday itself
// belongs to the week ending that day (sincePayday 0 → back 6), e.g.
// payday Sunday → weeks run Monday..Sunday.
const back = sincePayday === 0 ? 6 : sincePayday - 1;
return addDaysStr(today, -back); return addDaysStr(today, -back);
} }