Compare commits

..

76 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
JCEEE 404d4221ee 1.10.6 2026-09-11 13:05:08 +01:00
JCEEE 70b9c30c63 fix email bug for tags 2026-09-11 13:04:02 +01:00
JCEEE d0856648fa add prod logs 2026-09-11 12:31:31 +01:00
JCEEE 5d47ade845 1.10.5 2026-09-11 12:06:55 +01:00
JCEEE 75ce42dc94 fix email bug 2026-09-11 12:06:51 +01:00
JCEEE 0db74b2787 add pocket money count 2026-09-11 08:47:34 +01:00
JCEEE 65afb5a721 update migration of user fields 2026-09-11 07:40:38 +01:00
JCEEE 486f239fb1 add transparency for themes 2026-09-10 19:07:57 +01:00
JCEEE a5e3b80ff6 1.10.4 2026-09-10 18:54:58 +01:00
JCEEE 41033914b4 add platform demo fam 2026-09-10 18:54:38 +01:00
JCEEE 4730ab3eb9 add showboat fam and rotating data 2026-09-10 15:03:27 +01:00
JCEEE a270d965fe add theme patterns 2026-09-10 13:23:55 +01:00
JCEEE 27492b1558 send email on claim requests 2026-09-10 11:31:40 +01:00
JCEEE 897e203dab 1.10.3 2026-09-10 11:27:45 +01:00
JCEEE f802113cd1 loads of mobile ui layout upgrades 2026-09-10 11:27:27 +01:00
JCEEE 097106fa69 1.10.2 2026-09-09 20:58:54 +01:00
JCEEE 8a02640cdc add @ mentions in chat 2026-09-09 20:57:25 +01:00
JCEEE 3f95d50d2e clean pass update with rewards and completions 2026-09-09 19:02:29 +01:00
JCEEE 9d7915eb60 address email with resend 2026-09-08 22:41:11 +01:00
JCEEE 7ce127f482 fix chore count rewards for members 2026-09-08 21:17:58 +01:00
JCEEE 268c0157cb 1.10.1 2026-09-08 19:05:12 +01:00
JCEEE 6ed1805884 fix sveltekit load / preload bug on logout 2026-09-08 19:05:04 +01:00
JCEEE de6047f406 fix child pref updates 2026-09-08 18:34:58 +01:00
JCEEE 10d8b3d29d update login flow 2026-09-08 17:45:12 +01:00
JCEEE 02df79968e 1.10.0 2026-09-08 15:20:53 +01:00
JCEEE afc463a0f0 add new GDPR page and handle account creation email 2026-09-08 15:20:34 +01:00
JCEEE 9117c0ec70 simple additions 2026-09-08 09:52:50 +01:00
JCEEE 48279be756 1.9.4 2026-09-08 09:43:37 +01:00
JCEEE a5e60b5486 add new flag to disable some future features and make some updates to chat ux 2026-09-08 09:42:58 +01:00
JCEEE 45433c7c60 1.9.3 2026-09-07 20:53:42 +01:00
JCEEE 2abc9788a2 dynamic chore counts 2026-09-07 20:53:37 +01:00
JCEEE f2d6f8368c small fixes 2026-09-07 19:02:43 +01:00
JCEEE 7ced424a36 fix login bug 2026-09-07 17:45:17 +01:00
94 changed files with 9216 additions and 1741 deletions
+7 -5
View File
@@ -20,13 +20,13 @@
## 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
@@ -158,7 +160,7 @@ 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 |
+26 -4
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).
@@ -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 -19
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,34 +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 || ''
} 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`;
}
}
+30 -7
View File
@@ -7,7 +7,15 @@
children, children,
class: className, class: className,
selected = false selected = false
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props(); }: {
cols?: 1 | 2 | 3 | 4 | 5 | 6;
title?: string;
accent?: string;
scrollX?: boolean;
children?: any;
class?: string;
selected?: boolean;
} = $props();
</script> </script>
<div <div
@@ -44,6 +52,14 @@
.card.has-accent { .card.has-accent {
border-top: 3px solid var(--card-accent, #6366f1); border-top: 3px solid var(--card-accent, #6366f1);
} }
/* Background pattern active (has-pattern on the layout root): cards go
translucent so the pattern shows through. Desktop/tablet only — mobile
cards are already fully transparent. */
@media (min-width: 640px) {
:global(.has-pattern) .card {
background: #ffffffd9;
}
}
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */ /* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
@media (min-width: 640px) and (max-width: 1023px) { @media (min-width: 640px) and (max-width: 1023px) {
.card[data-cols='3'], .card[data-cols='3'],
@@ -53,12 +69,6 @@
grid-column: span 2; grid-column: span 2;
} }
} }
/* Mobile (1-col grid): every card is a full row */
@media (max-width: 639px) {
.card {
grid-column: span 1;
}
}
.card-header { .card-header {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
@@ -76,4 +86,17 @@
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Mobile (1-col grid): every card is a full row — declared last so it
overrides the base .card/.card-body rules (same specificity). */
@media (max-width: 639px) {
.card {
grid-column: span 1;
background: transparent;
border: none;
}
.card-body {
background: transparent;
padding: 0;
}
}
</style> </style>
+184 -20
View File
@@ -12,6 +12,67 @@
let container: HTMLDivElement | undefined = $state(); let container: HTMLDivElement | undefined = $state();
let idleTimer: ReturnType<typeof setTimeout> | undefined; let idleTimer: ReturnType<typeof setTimeout> | undefined;
// @-mention autocomplete — active while the draft ends with `@query` at the
// start of the message or after whitespace.
let token = $state<{ at: number; query: string } | null>(null);
let mentionIndex = $state(0);
function mentionTokenOf(text: string): { at: number; query: string } | null {
const atIdx = text.lastIndexOf('@');
if (atIdx === -1) return null;
if (atIdx > 0 && text[atIdx - 1] !== ' ' && text[atIdx - 1] !== '\n') return null;
return { at: atIdx, query: text.slice(atIdx + 1) };
}
let mentionList = $derived.by(() => {
if (!token) return [];
const q = token.query.toLowerCase();
const list = chatStore.members.filter((m) => m.id !== chatStore.actorId);
if (!q) return list;
return list.filter((m) => m.name.toLowerCase().startsWith(q));
});
function selectMention(m: { name: string }) {
if (!token) return;
draft = draft.slice(0, token.at) + '@' + m.name + ' ';
token = null;
mentionIndex = 0;
}
function onComposerInput(e: Event) {
token = mentionTokenOf((e.currentTarget as HTMLInputElement).value);
mentionIndex = 0;
onInput();
}
function onComposerKeydown(e: KeyboardEvent) {
if (token && mentionList.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
mentionIndex = (mentionIndex + 1) % mentionList.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
mentionIndex = (mentionIndex - 1 + mentionList.length) % mentionList.length;
return;
}
if (e.key === 'Tab') {
e.preventDefault();
selectMention(mentionList[mentionIndex]);
return;
}
if (e.key === 'Escape') {
token = null;
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}
const isOwn = (msg: any) => msg.authorId === chatStore.actorId; const isOwn = (msg: any) => msg.authorId === chatStore.actorId;
// Colour comes from the sender's user record (loaded with the family on // Colour comes from the sender's user record (loaded with the family on
@@ -39,6 +100,19 @@
} }
}); });
// Close the panel with Escape while it's open.
$effect(() => {
if (!chatStore.open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
async function send() { async function send() {
const text = draft.trim(); const text = draft.trim();
if (!text || sending) return; if (!text || sending) return;
@@ -82,18 +156,28 @@
draft = (draft ? draft + ' ' : '') + '@' + name + ' '; draft = (draft ? draft + ' ' : '') + '@' + name + ' ';
} }
// Render content, wrapping @mentions in a styled token. // Render content, wrapping @mentions in a styled token and https:// URLs in
// clickable links (opened in a new window). Mentions may be multi-word names
// (space-separated words after the @).
function renderContent(text: string) { function renderContent(text: string) {
const segs: { text: string; mention: boolean }[] = []; const segs: { text: string; mention: boolean; url: string | null }[] = [];
const re = /(@[A-Za-z0-9_.-]+)/g; const re = /(@[A-Za-z0-9_.-]+(?:[\s]+[A-Za-z0-9_.-]+)*|https?:\/\/[^\s]+)/g;
let last = 0; let last = 0;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) { while ((m = re.exec(text)) !== null) {
if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false }); if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false, url: null });
segs.push({ text: m[1], mention: true }); const tok = m[1];
if (tok.startsWith('@')) {
segs.push({ text: tok, mention: true, url: null });
} else {
const url = tok.replace(/[.,;:!?]+$/, '');
if (url) segs.push({ text: url, mention: false, url });
const rest = tok.slice(url.length);
if (rest) segs.push({ text: rest, mention: false, url: null });
}
last = m.index + m[0].length; last = m.index + m[0].length;
} }
if (last < text.length) segs.push({ text: text.slice(last), mention: false }); if (last < text.length) segs.push({ text: text.slice(last), mention: false, url: null });
return segs; return segs;
} }
@@ -123,15 +207,16 @@
{#if !isOwn(msg)} {#if !isOwn(msg)}
<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 <div class="bubble" style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}">
class="bubble" {#each renderContent(msg.content) as seg, i (msg.id + ':' + i)}
style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}"
>
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)}
{#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
> >
{:else if seg.url}
<a class="msg-link" href={seg.url} target="_blank" rel="noopener noreferrer"
>{seg.url}</a
>
{:else} {:else}
{seg.text} {seg.text}
{/if} {/if}
@@ -157,20 +242,34 @@
<div class="chat-composer"> <div class="chat-composer">
{#if error}<div class="chat-error">{error}</div>{/if} {#if error}<div class="chat-error">{error}</div>{/if}
<div class="composer-wrap">
{#if token && mentionList.length > 0}
<div class="mention-pop">
{#each mentionList as m, i}
<button
type="button"
class="mention-opt"
class:active={i === mentionIndex}
onclick={() => selectMention(m)}
onmouseenter={() => (mentionIndex = i)}
>
<span class="m-dot" style="background:{m.color}"></span>
<span class="m-name">{m.name}</span>
<span class="m-role">{m.role === 'parent' ? 'Parent' : ''}</span>
</button>
{/each}
</div>
{/if}
<input <input
type="text" type="text"
class="chat-input" class="chat-input"
placeholder="Message the family…" placeholder="Message the family…"
bind:value={draft} bind:value={draft}
oninput={onInput} oninput={onComposerInput}
onkeydown={(e) => { onkeydown={onComposerKeydown}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}}
disabled={sending} disabled={sending}
/> />
</div>
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send"> <button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
{@html sendIcon} {@html sendIcon}
</button> </button>
@@ -199,14 +298,15 @@
/* Mobile: full-screen fixed layer. */ /* Mobile: full-screen fixed layer. */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.chat-panel { .chat-panel {
width: 100vw; width: 100dvw;
} }
} }
.chat-head { .chat-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.85rem 1.25rem; height: 52px;
padding: 0 1.25rem;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
background: #4338ca; background: #4338ca;
color: #fff; color: #fff;
@@ -309,6 +409,11 @@
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
} }
.msg-link {
color: #6366f1;
text-decoration: underline;
word-break: break-all;
}
.chat-typing { .chat-typing {
min-height: 1.5rem; min-height: 1.5rem;
padding: 0 1.25rem; padding: 0 1.25rem;
@@ -325,6 +430,65 @@
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
align-items: center; align-items: center;
} }
.composer-wrap {
position: relative;
flex: 1;
}
.composer-wrap .chat-input {
width: 100%;
}
.mention-pop {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
right: 0;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
padding: 0.25rem;
max-height: 220px;
overflow-y: auto;
z-index: 120;
}
.mention-opt {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.45rem 0.6rem;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
text-align: left;
}
.mention-opt:hover,
.mention-opt.active {
background: #eef2ff;
}
.m-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: none;
}
.m-name {
font-weight: 600;
color: #1f2937;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-role {
font-size: 0.68rem;
font-weight: 600;
color: #9ca3af;
flex: none;
}
.chat-error { .chat-error {
position: absolute; position: absolute;
bottom: 4.5rem; bottom: 4.5rem;
+39 -1
View File
@@ -11,11 +11,18 @@
<div class="footer-inner px-8 py-16" class:sidebar> <div class="footer-inner px-8 py-16" class:sidebar>
<div class="footer-row flex items-center justify-between gap-2"> <div class="footer-row flex items-center justify-between gap-2">
<span class="footer-star">✦</span> <span class="footer-star">✦</span>
<div class="footer-center">
<span class="footer-copy" <span class="footer-copy"
>&copy; {new Date().getFullYear()} >&copy; {new Date().getFullYear()}
<span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span <span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span
> >
<aside class="footer-aside text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm"> <span class="footer-links">
<a href="/privacy">Privacy Policy</a>
</span>
</div>
<aside
class="footer-aside text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm"
>
<span>Built by humans</span> <span>Built by humans</span>
<a href="https://threejjjs.xyz"> <a href="https://threejjjs.xyz">
<h5 class="m-0 text-right font-tertiary text-xl md:text-3xl"> <h5 class="m-0 text-right font-tertiary text-xl md:text-3xl">
@@ -53,6 +60,24 @@
text-align: center; text-align: center;
padding: 0 1rem; padding: 0 1rem;
} }
.footer-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
}
.footer-links {
font-size: 0.8rem;
}
.footer-links a {
color: #4c1d95;
text-decoration: none;
}
.footer-links a:hover {
text-decoration: underline;
}
.footer-row { .footer-row {
position: relative; position: relative;
} }
@@ -71,5 +96,18 @@
.footer-aside { .footer-aside {
right: 1rem; right: 1rem;
} }
.footer-row {
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.footer-star {
position: static;
}
.footer-aside {
position: static;
text-align: center;
}
} }
</style> </style>
@@ -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>
@@ -0,0 +1,184 @@
<script lang="ts">
import '../theme-patterns.css';
import {
themeShades,
PATTERN_COUNT,
patternId,
THEME_SIZE_MIN,
THEME_SIZE_MAX,
THEME_SIZE_DEFAULT,
THEME_OPACITY_MIN,
THEME_OPACITY_MAX,
THEME_OPACITY_STEP,
THEME_OPACITY_DEFAULT
} from '$lib/theme';
let {
value = '',
color = '#6366f1',
size = THEME_SIZE_DEFAULT,
opacity = THEME_OPACITY_DEFAULT,
onchange,
onsize,
onopacity
}: {
value?: string;
color?: string;
size?: number;
opacity?: number;
onchange: (v: string) => void;
onsize?: (v: number) => void;
onopacity?: (v: number) => void;
} = $props();
let shades = $derived(themeShades(color));
// Inline vars beat the pattern class defaults; --s shrinks the pattern so
// thumbnails show a sample of the tiling.
let vars = $derived(
`--c1:${shades.c1};--c2:${shades.c2};--c3:${shades.c3};--c4:${shades.c4};--s:26px`
);
// Local slider state — bound directly to the inputs (no controlled-value
// re-render mid-drag), then emitted on input for the live preview.
let sizeVal = $state(size);
let opacityVal = $state(opacity);
// Keep local state in sync if the props ever change (e.g. after a save).
$effect(() => {
sizeVal = size;
});
$effect(() => {
opacityVal = opacity;
});
</script>
<div class="pattern-grid">
<button
type="button"
class="pattern-tile none"
class:selected={value === ''}
title="No background pattern"
onclick={() => onchange('')}
>
<span class="none-dot" style="background:{color}"></span>
</button>
{#each Array(PATTERN_COUNT) as _, i}
{@const id = patternId(i + 1)}
<button
type="button"
class="pattern-tile pattern-{id}"
class:selected={value === id}
title={`Pattern ${i + 1}`}
style={vars}
onclick={() => onchange(id)}
></button>
{/each}
</div>
{#if value !== ''}
{#if onsize}
<div class="slider-row">
<div class="slider-head">
<label for="theme-size">Pattern size</label>
<span class="slider-val">{sizeVal}</span>
</div>
<input
id="theme-size"
type="range"
min={THEME_SIZE_MIN}
max={THEME_SIZE_MAX}
step="0.5"
bind:value={sizeVal}
oninput={() => onsize?.(sizeVal)}
/>
<span class="slider-hint">scales with screen width</span>
</div>
{/if}
{#if onopacity}
<div class="slider-row">
<div class="slider-head">
<label for="theme-opacity">Opacity</label>
<span class="slider-val">{Math.round(opacityVal * 100)}%</span>
</div>
<input
id="theme-opacity"
type="range"
min={THEME_OPACITY_MIN}
max={THEME_OPACITY_MAX}
step={THEME_OPACITY_STEP}
bind:value={opacityVal}
oninput={() => onopacity?.(opacityVal)}
/>
<span class="slider-hint">max = current look</span>
</div>
{/if}
{/if}
<style>
.pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 0.5rem;
margin-bottom: 0.5rem;
/* Static grid — currently 19 tiles fit without scrolling. Padding keeps
the selected tile's outline from clipping at the top-left corner. */
padding: 0.4rem;
}
.pattern-tile {
aspect-ratio: 1;
border-radius: 8px;
border: 1px solid #e5e7eb;
cursor: pointer;
padding: 0;
/* No background here — the imported .pattern-pN classes supply it.
A local background (0,2,0) would beat .pattern-pN (0,1,0) and hide
every preview. */
transition: outline 0.12s ease;
}
.pattern-tile:hover {
border-color: #9ca3af;
}
.pattern-tile.selected {
outline: 3px solid #111827;
outline-offset: 2px;
}
.pattern-tile.none {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.none-dot {
width: 14px;
height: 14px;
border-radius: 50%;
}
.slider-row {
margin-top: 0.75rem;
font-size: 0.8rem;
color: #374151;
}
.slider-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.3rem;
}
.slider-head label {
font-weight: 600;
}
.slider-row input[type='range'] {
width: 100%;
display: block;
accent-color: #6366f1;
}
.slider-val {
font-weight: 700;
color: #4338ca;
}
.slider-hint {
color: #9ca3af;
font-size: 0.72rem;
display: block;
margin-top: 0.15rem;
}
</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>
@@ -147,6 +147,9 @@
} }
.price { margin: 0.55rem 0 0.15rem; line-height: 1; } .price { margin: 0.55rem 0 0.15rem; line-height: 1; }
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; } .amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
@media (max-width: 639px) {
.amount { font-size: 1.5em; }
}
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; } .period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; } .blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
@@ -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>
+54 -8
View File
@@ -11,8 +11,14 @@
prefsIcon prefsIcon
} from './icons'; } from './icons';
import FamDone from './FamDone.svelte'; import FamDone from './FamDone.svelte';
import { famStore } from '$lib/stores/fam.svelte';
let { famName = '', session = null, isParent = false, role = 'child' } = $props(); // Outstanding claim requests (children asked → parent still to issue).
let requestedClaims = $derived(
famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0
);
let { famName = '', session = null, isParent = false, role = 'child', isDemo = false } = $props();
let famSlug = $derived(page.data.famSlug ?? page.params.fam); let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || ''); let memberName = $derived(session?.memberName || page.params.username || '');
@@ -37,10 +43,10 @@
); );
let footerItems = $derived([ let footerItems = $derived([
...(memberName ...(memberName && !isDemo
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
: []), : []),
...(isParent && memberName ...(isParent && memberName && !isDemo
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
: []), : []),
{ {
@@ -52,26 +58,38 @@
</script> </script>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <a class="sidebar-header" href="/" aria-label="Go to homepage">
<span class="app-icon">✦</span> <span class="app-icon">✦</span>
<span class="app-name"><FamDone /></span> <span class="app-name"><FamDone /></span>
</div> </a>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
{#each navItems as item} {#each navItems as item}
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}> <a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
{@html item.icon} {@html item.icon}
<span class="nav-label">{item.label}</span> <span class="nav-label">{item.label}</span>
{#if isParent && item.label === 'Dashboard' && requestedClaims > 0}
<span class="nav-badge">{requestedClaims}</span>
{/if}
</a> </a>
{/each} {/each}
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
{#each footerItems as item} {#each footerItems as item}
{#if item.href === '/logout'}
<form method="POST" action="/logout" class="nav-item-form">
<button type="submit" class="nav-item nav-item-button" aria-label="Log out">
{@html item.icon}
<span class="nav-label">{item.label}</span>
</button>
</form>
{:else}
<a href={item.href} class="nav-item"> <a href={item.href} class="nav-item">
{@html item.icon} {@html item.icon}
<span class="nav-label">{item.label}</span> <span class="nav-label">{item.label}</span>
</a> </a>
{/if}
{/each} {/each}
</div> </div>
</aside> </aside>
@@ -96,9 +114,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem 0.75rem; padding: 0 0.75rem;
border-bottom: 1px solid #3730a3; border-bottom: 1px solid #3730a3;
min-height: 52px; height: 52px;
box-sizing: border-box;
text-decoration: none;
color: inherit;
} }
.app-icon { .app-icon {
font-size: 1.3rem; font-size: 1.3rem;
@@ -135,11 +156,36 @@
margin: 0 0.3rem; margin: 0 0.3rem;
white-space: nowrap; white-space: nowrap;
transition: background 0.15s; transition: background 0.15s;
position: relative;
}
.nav-badge {
position: absolute;
top: 4px;
right: 6px;
background: #f59e0b;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
padding: 2px 6px;
border-radius: 999px;
} }
.nav-item:hover { .nav-item:hover {
background: #3730a3; background: #3730a3;
color: #e0e7ff; color: #e0e7ff;
} }
.nav-item-form {
margin: 0 0.3rem;
}
.nav-item-button {
font-family: inherit;
background: none;
border: none;
box-sizing: border-box;
text-align: left;
cursor: pointer;
width: 100%;
}
.nav-item.active { .nav-item.active {
background: #4338ca; background: #4338ca;
color: #fff; color: #fff;
@@ -155,7 +201,7 @@
} }
.sidebar-header { .sidebar-header {
justify-content: center; justify-content: center;
padding: 1rem 0; padding: 0;
} }
.app-name { .app-name {
display: none; display: none;
+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;
+81 -2
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">
@@ -73,6 +88,14 @@
<style> <style>
.view-header { .view-header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-wrap: wrap;
}
@media (max-width: 639px) {
/* Stack title above tools so the header doesn't squish on phones */
.view-header {
flex-direction: column;
align-items: stretch;
}
} }
.view-title-group { .view-title-group {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -82,6 +105,11 @@
color: #111827; color: #111827;
margin: 0; margin: 0;
} }
@media (max-width: 639px) {
.view-title {
font-size: 1.25rem;
}
}
.view-subtitle { .view-subtitle {
font-size: 0.9rem; font-size: 0.9rem;
color: #6b7280; color: #6b7280;
@@ -90,11 +118,19 @@
.view-tools { .view-tools {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem;
/* Tools row takes the full header width so the title never gets squeezed */
width: 100%;
}
/* Immediate tools (tab bar / week nav / sort) grow to span the row; they
wrap to their own full-width line only when they don't fit side by side */
.view-tools > * {
flex: 1 1 auto;
} }
.tab-bar { .tab-bar {
display: flex; display: flex;
justify-content: space-between;
gap: 2px; gap: 2px;
background: #f3f4f6; background: #f3f4f6;
border-radius: 6px; border-radius: 6px;
@@ -118,8 +154,16 @@
.week-nav { .week-nav {
display: flex; display: flex;
align-items: center; align-items: center;
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;
@@ -132,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;
+72 -33
View File
@@ -1,33 +1,72 @@
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>' export const dashboardIcon =
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>';
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>' export const choresIcon =
export const moneyIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>';
export const coinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>' export const rewardsIcon =
export const walletIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>';
export const circleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>' export const moneyIcon =
export const handCoinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>';
export const gemIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>' export const coinsIcon =
export const archiveIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>';
export const todoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>' export const walletIcon =
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>';
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>' export const circleIcon =
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
export const prefsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' export const handCoinsIcon =
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>';
export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>' export const gemIcon =
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="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>';
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>' export const archiveIcon =
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"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
export const checkCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>' export const todoIcon =
export const revokeIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>';
export const targetIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>' export const homeIcon =
export const calendarIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
export const sunIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>' export const settingsIcon =
export const lockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
export const clockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' export const logoutIcon =
export const giftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
export const chevronLeftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 6 -6 6 6 6"/></svg>' export const prefsIcon =
export const chevronRightIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m9 6 6 6 -6 6"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
export const alertTriangleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>' export const chevronLeft =
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="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
export const infoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>' export const chevronRight =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
export const bellIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
export const chatIcon =
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
export const 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 =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
export const checkCircleIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
export const revokeIcon =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
export const targetIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
export const calendarIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
export const sunIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
export const lockIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
export const clockIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
export const giftIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>';
export const chevronLeftIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 6 -6 6 6 6"/></svg>';
export const chevronRightIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 6 6 6 -6 6"/></svg>';
export const alertTriangleIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>';
export const xCircleIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>';
export const infoIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>';
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>';
+5
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';
@@ -13,3 +14,7 @@ export { default as AuthShell } from './AuthShell.svelte';
export { default as NoticeDialog } from './NoticeDialog.svelte'; 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 PinPad } from './PinPad.svelte';
export { default as SharedPicker } from './SharedPicker.svelte';
export { default as JoinPinFlow } from './JoinPinFlow.svelte';
@@ -0,0 +1,46 @@
// Code-specific email templates for successful access-code redemption.
// Key = the raw access-code value a family enters (e.g. "copppermill03").
// Add an entry here and its custom email is sent when that code is used.
// If no entry matches, `defaultAccessEmailTemplate` is used.
export interface AccessEmailVars {
famName: string;
codeName: string;
dashboardUrl: string;
}
export interface AccessEmailTemplate {
subject: (vars: AccessEmailVars) => string;
html: (vars: AccessEmailVars) => string;
}
const DEFAULT_TEMPLATE: AccessEmailTemplate = {
subject: ({ famName }) => `Your ${famName} access is unlocked`,
html: ({ famName, codeName, dashboardUrl }) => `
<p>Hi${famName ? ` ${famName}` : ' there'}! 🎉</p>
<p>Your access code (<strong>${codeName}</strong>) has been applied successfully.</p>
<p>Your <strong>${famName}</strong> family on <strong>FamDone</strong> is now unlocked.</p>
<p><a href="${dashboardUrl}">Open your family dashboard</a> to add kids, set up chores, and get going.</p>
<p>If you have any questions, just reply to this email.</p>
<p>Talk soon,<br />The FamDone team</p>
`
};
export const accessEmailTemplates: Record<string, AccessEmailTemplate> = {
copppermill03: {
subject: ({ famName }) => `Save a spot for ${famName} — access unlocked`,
html: ({ famName, codeName, dashboardUrl }) => `
<p>Hi${famName ? ` ${famName}` : ' there'}! 🎉</p>
<p>Your access code (<strong>${codeName}</strong>) has been applied successfully.</p>
<p>Your <strong>${famName}</strong> family on <strong>FamDone</strong> is now unlocked.</p>
<p><a href="${dashboardUrl}">Open your family dashboard</a> to add kids, set up chores, and get going.</p>
<p>If you have any questions, just reply to this email.</p>
<p>Talk soon,<br />The FamDone team</p>
`
}
};
// Resolve the template for a redeemed code, falling back to the default.
export function getAccessEmailTemplate(codeValue: string): AccessEmailTemplate {
return accessEmailTemplates[codeValue] || DEFAULT_TEMPLATE;
}
+136 -2
View File
@@ -1,17 +1,35 @@
import { Resend } from 'resend'; import { Resend } from 'resend';
import { RESEND_API } from '$app/env/private'; import { RESEND_API } from '$app/env/private';
import { getAccessEmailTemplate } from './email-templates';
import { getPlatformFlags } from '$lib/server/platform';
const resend = new Resend(String(RESEND_API)); const resend = new Resend(String(RESEND_API));
let demoCache: { value: boolean; at: number } | null = null;
const DEMO_TTL_MS = 10_000;
async function isDemoMode(): Promise<boolean> {
if (demoCache && Date.now() - demoCache.at < DEMO_TTL_MS) return demoCache.value;
try {
const flags = await getPlatformFlags();
demoCache = { value: flags.demo === true, at: Date.now() };
return flags.demo === true;
} catch {
demoCache = { value: false, at: Date.now() };
return false;
}
}
export async function sendPasswordResetEmail(opts: { export async function sendPasswordResetEmail(opts: {
to: string; to: string;
resetLink: string; resetLink: string;
famName: string; famName: string;
}) { }) {
if (await isDemoMode()) return;
const { to, resetLink, famName } = opts; const { to, resetLink, famName } = opts;
await resend.emails.send({ await resend.emails.send({
from: 'no-reply@famchamp.ai', from: 'no-reply@walthamstow.xyz',
to, to,
subject: `Reset your password for ${famName}`, subject: `Reset your password for ${famName}`,
html: ` html: `
@@ -29,10 +47,11 @@ export async function sendParentInviteEmail(opts: {
famName: string; famName: string;
otp: string; otp: string;
}) { }) {
if (await isDemoMode()) return;
const { to, inviteLink, famName, otp } = opts; const { to, inviteLink, famName, otp } = opts;
await resend.emails.send({ await resend.emails.send({
from: 'no-reply@famchamp.ai', from: 'no-reply@walthamstow.xyz',
to, to,
subject: `You're invited to ${famName} on FamDone`, subject: `You're invited to ${famName} on FamDone`,
html: ` html: `
@@ -43,3 +62,118 @@ export async function sendParentInviteEmail(opts: {
` `
}); });
} }
export async function sendAccessUnlockedEmail(opts: {
to: string;
famName: string;
codeName: string;
codeValue: string;
dashboardUrl: string;
}) {
if (await isDemoMode()) return;
const { to, famName, codeName, codeValue, dashboardUrl } = opts;
const template = getAccessEmailTemplate(codeValue);
const vars = { famName, codeName, dashboardUrl };
await resend.emails.send({
from: 'no-reply@walthamstow.xyz',
to,
subject: template.subject(vars),
html: template.html(vars)
});
}
export async function sendWelcomeEmail(opts: {
to: string;
famName: string;
dashboardUrl: string;
}) {
if (await isDemoMode()) return;
const { to, famName, dashboardUrl } = opts;
await resend.emails.send({
from: 'no-reply@walthamstow.xyz',
to,
subject: `Welcome to FamDone${famName ? `, ${famName}` : ''}!`,
html: `
<p>Hi${famName ? ` ${famName}` : ' there'}! 👋</p>
<p>Welcome to <strong>FamDone</strong> — your family chore and pocket-money app.</p>
<p>Your family is all set up. Here's how to get going:</p>
<ul>
<li>Open your <a href="${dashboardUrl}">family dashboard</a>.</li>
<li>Add your kids and create join codes from <strong>Family Settings</strong>.</li>
<li>Set up chores, rewards, and allowances.</li>
</ul>
<p>
If you haven't already, you can pick a plan and pay from your dashboard to
unlock your family.
</p>
<p>If you have any questions, just reply to this email — we're happy to help.</p>
<p>Talk soon,<br />The FamDone team</p>
`
});
}
// A parent was @mentioned in family chat — tell them about it with a link
// straight back to the family site. Message text is HTML-escaped.
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export async function sendMentionEmail(opts: {
to: string;
parentName: string;
authorName: string;
message: string;
dashboardUrl: string;
}) {
const { to, parentName, authorName, message, dashboardUrl } = opts;
await resend.emails.send({
from: 'no-reply@walthamstow.xyz',
to,
subject: `${authorName} mentioned you in family chat`,
html: `
<p>Hi ${escHtml(parentName)},</p>
<p><strong>${escHtml(authorName)}</strong> mentioned you in your family chat:</p>
<blockquote style="margin:1rem 0;padding:0.6rem 1rem;border-left:3px solid #6366f1;background:#f5f5ff;color:#374151;border-radius:6px">${escHtml(message)}</blockquote>
<p><a href="${dashboardUrl}">Open your family dashboard</a> to read and reply.</p>
<p>Talk soon,<br />The FamDone team</p>
`
});
}
// A child asked to collect a reward — ping the parent(s) with the ask and a
// link straight to the Claims card on the admin dashboard.
export async function sendClaimRequestEmail(opts: {
to: string;
parentName: string;
childName: string;
rewardName: string;
rewardValue: string;
dashboardUrl: string;
}) {
if (await isDemoMode()) return;
const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts;
await resend.emails.send({
from: 'no-reply@walthamstow.xyz',
to,
subject: `${childName} asked to claim ${rewardName}`,
html: `
<p>Hi ${escHtml(parentName)},</p>
<p><strong>${escHtml(childName)}</strong> asked to collect a reward:</p>
<p>
<strong>${escHtml(rewardName)}</strong>
${rewardValue ? `— ${escHtml(rewardValue)}` : ''}
</p>
<p><a href="${dashboardUrl}">Open the Claims card on your dashboard</a> to review and issue it.</p>
<p>Talk soon,<br />The FamDone team</p>
`
});
}
+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 };
} }
+546 -16
View File
@@ -1,11 +1,13 @@
import { SCHEMA_PLAN } from '@shared/pb/schema'; import { SCHEMA_PLAN } from '@shared/pb/schema';
import { PB_ENDPOINT } from '$lib/server/pocketbase'; import { PB_ENDPOINT } from '$lib/server/pocketbase';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private';
import { getPlatformFlags } from '$lib/server/platform';
let token: string | null = null; let token: string | null = null;
let tokenExpiry = 0;
async function auth(): Promise<string> { async function auth(): Promise<string> {
if (token) return token; if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -14,9 +16,16 @@ async function auth(): Promise<string> {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token; token = data.token;
// PB JWTs expire in 1 hour; refresh 5 min early.
tokenExpiry = Date.now() + 55 * 60 * 1000;
return token!; return token!;
} }
async function isDemo(): Promise<boolean> {
const flags = await getPlatformFlags();
return flags.demo === true;
}
async function getCollection(name: string): Promise<any | null> { async function getCollection(name: string): Promise<any | null> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
@@ -64,12 +73,10 @@ async function createRecord(collection: string, data: any): Promise<void> {
} }
} }
// Seed a default season (Holidays) per family so the family-admin season // Seed two default seasons (Term + Holidays) per family so the family-admin
// picker always has at least one option. "Term time" is intentionally NOT // season picker always has options from day one and they can tick either on/off.
// auto-seeded — it's a UK-school-specific term that not every family wants, // Only seeds when a family has zero seasons, so user deletions stick (a deleted
// and force-seeding it meant a deleted "Term time" kept coming back on every // default never comes back on boot as long as at least one season remains).
// boot. Families that want it can add it themselves.
// Only seeds when a family has zero seasons, so user deletions stick.
async function ensureDefaultSeasons(): Promise<void> { async function ensureDefaultSeasons(): Promise<void> {
const famsCol = await getCollection('fams'); const famsCol = await getCollection('fams');
if (!famsCol) return; if (!famsCol) return;
@@ -78,7 +85,10 @@ async function ensureDefaultSeasons(): Promise<void> {
headers: { Authorization: `Bearer ${t}` } headers: { Authorization: `Bearer ${t}` }
}); });
const fams = (await famsRes.json()).items || []; const fams = (await famsRes.json()).items || [];
const defaults = [{ name: 'Holidays', color: '#f59e0b', active: true }]; const defaults = [
{ name: 'Term', color: '#6366f1', active: true },
{ name: 'Holidays', color: '#f59e0b', active: true }
];
for (const fam of fams) { for (const fam of fams) {
const sRes = await fetch( const sRes = await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
@@ -145,6 +155,18 @@ async function ensureUsers(ids: Record<string, string>): Promise<void> {
usersCol.fields.push({ name: 'color', type: 'text', required: false }); usersCol.fields.push({ name: 'color', type: 'text', required: false });
changed = true; changed = true;
} }
if (!has('pattern')) {
usersCol.fields.push({ name: 'pattern', type: 'text', required: false });
changed = true;
}
if (!has('themeSize')) {
usersCol.fields.push({ name: 'themeSize', type: 'text', required: false });
changed = true;
}
if (!has('themeOpacity')) {
usersCol.fields.push({ name: 'themeOpacity', type: 'text', required: false });
changed = true;
}
if (!has('passwordResetToken')) { if (!has('passwordResetToken')) {
usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false }); usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false });
changed = true; changed = true;
@@ -240,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
@@ -355,7 +425,7 @@ async function seedPlatform(): Promise<void> {
const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ label: 'global', flags: { debug: false } }) body: JSON.stringify({ label: 'global', flags: { debug: false, rewardsTargets: false } })
}); });
const c = await created.json(); const c = await created.json();
if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`);
@@ -382,9 +452,41 @@ 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.');
} }
// Add theme + newer auth fields to `users` on installs where the collection
// already exists (fresh installs get them via ensureUsers). Idempotent —
// only adds missing fields. Mirrors ensureFamFields.
async function ensureUserFields(): Promise<void> {
const usersCol = await getCollection('users');
if (!usersCol) return;
const has = (n: string) => usersCol.fields.some((f: any) => f.name === n);
const needed: any[] = [];
if (!has('pattern')) {
needed.push({ name: 'pattern', type: 'text', required: false });
}
if (!has('themeSize')) {
needed.push({ name: 'themeSize', type: 'text', required: false });
}
if (!has('themeOpacity')) {
needed.push({ name: 'themeOpacity', type: 'text', required: false });
}
if (!has('passwordResetToken')) {
needed.push({ name: 'passwordResetToken', type: 'text', required: false });
}
if (!has('passwordResetExpiry')) {
needed.push({ name: 'passwordResetExpiry', type: 'text', required: false });
}
if (!has('partyEmoji')) {
needed.push({ name: 'partyEmoji', type: 'text', required: false });
}
if (needed.length) {
await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] });
}
}
// Add the access-gating fields to `fams` on installs where it already exists // Add the access-gating fields to `fams` on installs where it already exists
// (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing // (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing
// fields. // fields.
@@ -421,22 +523,50 @@ export async function migrate(): Promise<void> {
await ensureSchema(); await ensureSchema();
// Runs even when the schema already exists (unlike ensureSchema's early // Runs even when the schema already exists (unlike ensureSchema's early
// 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 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()) {
await seedDemoFamily();
await seedDemoCompletions();
}
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> {
const col = await getCollection('assigned_chores'); const col = await getCollection('assigned_chores');
if (!col) return; if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n); const has = (n: string) => col.fields.some((f: any) => f.name === n);
const fields = [...col.fields];
const needed: any[] = []; const needed: any[] = [];
if (!has('description')) needed.push({ name: 'description', type: 'text', required: false }); if (!has('description')) needed.push({ name: 'description', type: 'text', required: false });
if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false }); if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false });
@@ -444,27 +574,31 @@ async function ensureAssignedChoreFields(): Promise<void> {
if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false }); if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false });
// Shared chores: memberId becomes optional, shared boolean tracks original mode // Shared chores: memberId becomes optional, shared boolean tracks original mode
if (!has('shared')) needed.push({ name: 'shared', type: 'bool', required: false }); if (!has('shared')) needed.push({ name: 'shared', type: 'bool', required: false });
let changed = false;
if (needed.length) {
fields.push(...needed);
changed = true;
}
// Todos can be celebration-only (emoji) as well as points/money. // Todos can be celebration-only (emoji) as well as points/money.
let changed = !!needed.length; const typeField = fields.find((f: any) => f.name === 'type');
const typeField = col.fields.find((f: any) => f.name === 'type');
if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) { if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) {
typeField.values = [...typeField.values, 'emoji']; typeField.values = [...typeField.values, 'emoji'];
changed = true; changed = true;
} }
// Direct todos (created by a parent, not from a template) have no templateId. // Direct todos (created by a parent, not from a template) have no templateId.
const tplField = col.fields.find((f: any) => f.name === 'templateId'); const tplField = fields.find((f: any) => f.name === 'templateId');
if (tplField && tplField.required) { if (tplField && tplField.required) {
tplField.required = false; tplField.required = false;
changed = true; changed = true;
} }
// memberId must be optional for shared chores (null = shared with all members) // memberId must be optional for shared chores (null = shared with all members)
const memberField = col.fields.find((f: any) => f.name === 'memberId'); const memberField = fields.find((f: any) => f.name === 'memberId');
if (memberField && memberField.required) { if (memberField && memberField.required) {
memberField.required = false; memberField.required = false;
changed = true; changed = true;
} }
if (changed) { if (changed) {
await updateCollection(col.id, { ...col, fields: [...col.fields] }); await updateCollection(col.id, { ...col, fields });
} }
const comp = await getCollection('completions'); const comp = await getCollection('completions');
if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) { if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) {
@@ -525,6 +659,21 @@ async function ensureBonusFields(): Promise<void> {
fields.push({ name: 'targetChoreId', type: 'text', required: false }); fields.push({ name: 'targetChoreId', type: 'text', required: false });
changed = true; changed = true;
} }
if (name === 'bonus_configs' && !has('targetChoreIds')) {
fields.push({ name: 'targetChoreIds', type: 'json', required: false });
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) {
@@ -752,4 +901,385 @@ async function seedGlobalTemplates(): Promise<void> {
console.log('[migrate] Seeded global templates.'); console.log('[migrate] Seeded global templates.');
} }
// ── Demo family seed ────────────────────────────────────────────────────────
// Creates a "Showboaters" family with one admin (demo) and three kids
// (Juno, Jackson, Jalipo). Idempotent — skips if the family already exists.
// The parent password is `demo1234` (PB enforces ≥8 chars). Children's
// derived passwords are handled by the normal member-otp flow.
const DEMO_FAM = {
name: 'Showboaters',
slug: 'showboaters',
parentName: 'Demo',
parentEmail: 'demo@famdone.xyz',
parentPassword: 'demo1234',
children: [
{ name: 'Juno', color: '#f472b6' },
{ name: 'Jackson', color: '#60a5fa' },
{ name: 'Jalipo', color: '#34d399' }
]
};
async function seedDemoFamily(): Promise<void> {
const t = await auth();
// Check if the demo family already exists
const famRes = await fetch(
`${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const famData = await famRes.json();
if (famData?.items?.length) return; // already seeded
console.log('[migrate] Seeding demo family "Showboaters"...');
// 1. Create the family
const createdFam = await fetch(`${PB_ENDPOINT}/api/collections/fams/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
name: DEMO_FAM.name,
slug: DEMO_FAM.slug,
timezone: 'auto',
paymentMode: 'none',
active: false
})
});
const fam = await createdFam.json();
if (!createdFam.ok) throw new Error(`Seed demo fam failed: ${JSON.stringify(fam)}`);
const famId = fam.id;
// 2. Create the parent (admin) user
const parentUsername = `${DEMO_FAM.slug}:${DEMO_FAM.parentName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const createdParent = await fetch(`${PB_ENDPOINT}/api/collections/users/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
username: parentUsername,
name: DEMO_FAM.parentName,
email: DEMO_FAM.parentEmail,
emailVisibility: false,
password: DEMO_FAM.parentPassword,
passwordConfirm: DEMO_FAM.parentPassword,
famId,
role: 'parent'
})
});
if (!createdParent.ok) {
const pd = await createdParent.json();
throw new Error(`Seed demo parent failed: ${JSON.stringify(pd)}`);
}
// 3. Create settings for the family
await fetch(`${PB_ENDPOINT}/api/collections/settings/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ famId })
});
// 4. Apply access code (dev123) to unlock the family
const codeRes = await fetch(
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='dev123'&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const codeData = await codeRes.json();
const accessCode = codeData?.items?.[0];
if (accessCode) {
await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${famId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
paymentMode: 'code',
active: true,
accessCodeId: accessCode.id,
accessCodeEnteredAt: new Date().toISOString()
})
});
}
// 5. Create child users (via the same derive-password path as member-otp)
for (const child of DEMO_FAM.children) {
const childHandle = child.name.toLowerCase().replace(/[^a-z0-9]/g, '');
const childUsername = `${DEMO_FAM.slug}:${childHandle}`;
const childPassword = `${String(MEMBER_SECRET)}${DEMO_FAM.slug}${childHandle}`;
await fetch(`${PB_ENDPOINT}/api/collections/users/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
username: childUsername,
name: child.name,
color: child.color,
password: childPassword,
passwordConfirm: childPassword,
famId,
role: 'child'
})
}).catch((e) => {
console.warn(`[migrate] Failed to create demo child ${child.name}:`, e);
});
// Auto-create pocket-money droplet
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId,
name: 'Pocket Money',
target: 'individual',
type: 'threshold',
thresholdType: 'percent',
occurrence: 'recurring',
rewardType: 'cash',
rewardValue: '',
criteriaValue: 50,
period: 'weekly',
status: 'active',
isPocketMoney: true
})
}).catch(() => {});
}
console.log('[migrate] ✓ Demo family "Showboaters" seeded');
console.log(`[migrate] Login: ${DEMO_FAM.parentEmail} / ${DEMO_FAM.parentPassword}`);
}
// ── Demo rotating data ──────────────────────────────────────────────────────
// On every boot, if the demo family exists and doesn't already have today's
// completions, wipe and regenerate 21 days of deterministic completion data.
// This ensures the demo always shows a realistic 3-week history relative to
// the current date, without manual re-seeding.
// Simple seeded PRNG (mulberry32) so the same data is produced every time.
function demoRng(seed: number): () => number {
let s = seed | 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function dateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
// Each kid's "completion personality" — Juno is reliable, Jackson solid, Jalipo spotty.
// Maps assigned chore customName → weekday (0=Sun) → probability of completion.
const COMPLETION_PROFILES: Record<string, Record<number, Record<string, number>>> = {
Juno: {
0: { default: 0.85 }, // Sun
1: { default: 0.95 }, // Mon
2: { default: 0.9 },
3: { default: 0.95 },
4: { default: 0.9 },
5: { default: 0.85 }, // Fri
6: { default: 0.8 } // Sat
},
Jackson: {
0: { default: 0.7 },
1: { default: 0.9 },
2: { default: 0.85 },
3: { default: 0.8 },
4: { default: 0.75 },
5: { default: 0.7 },
6: { default: 0.6 }
},
Jalipo: {
0: { default: 0.4 },
1: { default: 0.75 },
2: { default: 0.65 },
3: { default: 0.7 },
4: { default: 0.6 },
5: { default: 0.5 },
6: { default: 0.35 }
}
};
async function seedDemoCompletions(): Promise<void> {
const t = await auth();
// Find the demo family
const famRes = await fetch(
`${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const famData = await famRes.json();
const fam = famData?.items?.[0];
if (!fam) return; // demo family not seeded yet
const famId = fam.id;
// Check if today's completions already exist (idempotent)
const today = dateStr(new Date());
const todayComps = await fetch(
`${PB_ENDPOINT}/api/collections/completions/records?filter=${encodeURIComponent(`famId='${famId}' && date='${today}'`)}&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
if ((await todayComps.json())?.items?.length) {
console.log('[migrate] Demo data already rotated for today — skipping.');
return;
}
console.log('[migrate] Rotating demo data for Showboaters...');
// Fetch members, assigned chores
const [membersRes, assignedRes] = await Promise.all([
fetch(
`${PB_ENDPOINT}/api/collections/users/records?filter=${encodeURIComponent(`famId='${famId}' && role='child'`)}&perPage=10`,
{
headers: { Authorization: `Bearer ${t}` }
}
),
fetch(
`${PB_ENDPOINT}/api/collections/assigned_chores/records?filter=famId='${famId}'&perPage=100`,
{
headers: { Authorization: `Bearer ${t}` }
}
)
]);
const members: any[] = (await membersRes.json())?.items || [];
const assigned: any[] = (await assignedRes.json())?.items || [];
if (!members.length || !assigned.length) return;
// Delete existing completions and weekly_history for the demo family
const [oldCompsRes, oldHistoryRes] = await Promise.all([
fetch(
`${PB_ENDPOINT}/api/collections/completions/records?filter=famId='${famId}'&perPage=500`,
{
headers: { Authorization: `Bearer ${t}` }
}
),
fetch(
`${PB_ENDPOINT}/api/collections/weekly_history/records?filter=famId='${famId}'&perPage=500`,
{
headers: { Authorization: `Bearer ${t}` }
}
)
]);
const oldComps: any[] = (await oldCompsRes.json())?.items || [];
const oldHistory: any[] = (await oldHistoryRes.json())?.items || [];
for (const r of oldComps) {
await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${t}` }
}).catch(() => {});
}
for (const r of oldHistory) {
await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${t}` }
}).catch(() => {});
}
// Generate 21 days of completions (today - 20 → today)
const todayDate = new Date();
todayDate.setUTCHours(12, 0, 0, 0); // anchor at noon UTC
const startDate = addDays(todayDate, -20);
// Track per-member-per-week totals for weekly_history
type WeekKey = string; // "YYYY-MM-DD" (week start: day after payday)
const weekTotals: Record<
string,
Record<string, { points: number; money: number; count: number }>
> = {};
const demoPayday = fam.payday ?? 1;
for (const member of members) {
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
for (let d = 0; d < 21; d++) {
const day = addDays(startDate, d);
const ds = dateStr(day);
const dow = day.getUTCDay(); // 0=Sun
// Compute week start (payday-anchored: weeks close on payday, so
// the bucket opens the day after — same rule as weekStart()).
const dowNum = day.getUTCDay(); // 0=Sun
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][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
const dayProfile = profile[dow] || profile[0];
// Find this member's assigned chores
const memberChores = assigned.filter(
(a: any) => a.memberId === member.id || (!a.memberId && a.shared)
);
for (const chore of memberChores) {
// Weekly chores: only complete on the week's first day
// (day after payday).
if (chore.frequency === 'weekly' && dow !== (demoPayday + 1) % 7) continue;
const prob = dayProfile[chore.customName] || dayProfile.default;
const rng = demoRng(
famId.length * 1000 + member.id.length * 100 + d * 10 + (chore.id?.charCodeAt(0) || 0)
);
if (rng() > prob) continue; // missed this day
// Create completion record
await fetch(`${PB_ENDPOINT}/api/collections/completions/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId,
memberId: member.id,
assignedChoreId: chore.id,
date: ds
})
}).catch(() => {});
// Track totals
const val = Number(chore.value) || 0;
if (chore.type === 'points') {
weekTotals[member.id][ws].points += val;
} else if (chore.type === 'money') {
weekTotals[member.id][ws].money += val;
}
weekTotals[member.id][ws].count++;
}
}
}
// Create weekly_history records for each week that has data
for (const memberId of Object.keys(weekTotals)) {
for (const ws of Object.keys(weekTotals[memberId])) {
const totals = weekTotals[memberId][ws];
if (totals.count === 0) continue;
await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId,
memberId,
weekStart: ws,
pointsEarned: totals.points,
moneyEarned: totals.money,
choresCompleted: totals.count,
bonusEarned: 0
})
}).catch(() => {});
}
}
console.log('[migrate] ✓ Demo completions rotated (21 days)');
}
export { GLOBAL_TEMPLATE_ICONS }; export { GLOBAL_TEMPLATE_ICONS };
export { seedDemoCompletions };
+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;
}
+31 -10
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);
} }
}; };
+167 -202
View File
@@ -5,15 +5,42 @@ 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 { 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');
} }
const getRewardValue = (cfg: any): number => {
const n = Number(cfg.rewardValue);
// Prize rewards carry the payout in `label`/rewardValue text ("fluffy toy"),
// not a number — but PB rejects value 0 on the required number field, so
// fall back to 1 and let the label carry the meaning.
return Number.isFinite(n) && n > 0 ? n : 1;
};
function claimableStamp(cfg: any, payday: number, tz: string) { function claimableStamp(cfg: any, payday: number, tz: string) {
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') { if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
return { claimable: 'immediate', settleDate: '' }; return { claimable: 'immediate', settleDate: '' };
@@ -21,7 +48,24 @@ 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 {
if (
cfg.targetChoreIds &&
typeof cfg.targetChoreIds === 'object' &&
cfg.targetChoreIds[memberId]
) {
return cfg.targetChoreIds[memberId];
}
return cfg.targetChoreId || undefined;
} }
export async function evaluateFam(pb: any, famId: string) { export async function evaluateFam(pb: any, famId: string) {
@@ -44,13 +88,14 @@ 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.
const dueByMember: Record<string, number> = {}; const dueByMember: Record<string, number> = {};
for (const a of allAssigned) { for (const a of allAssigned) {
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1); if (a.memberId)
dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
} }
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1; const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
@@ -58,17 +103,18 @@ export async function evaluateFam(pb: any, famId: string) {
// Pocket money pauses until the parent sets an amount. // Pocket money pauses until the parent sets an amount.
if (cfg.isPocketMoney && !cfg.rewardValue) continue; if (cfg.isPocketMoney && !cfg.rewardValue) continue;
const win = bonusWindow(cfg, paydayEval, tzEval); const win = bonusWindow(cfg, paydayEval, tzEval);
let periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
// Count-type rewards can pin against a single assigned chore (targetChoreId).
// When set, only completions of that specific chore count toward the target.
if (cfg.type === 'count' && cfg.targetChoreId) {
periodCompletions = periodCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
}
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) => {
@@ -82,7 +128,7 @@ export async function evaluateFam(pb: any, famId: string) {
memberId, memberId,
bonusConfigId: cfg.id, bonusConfigId: cfg.id,
label, label,
value: Number(cfg.rewardValue) || 0, value: getRewardValue(cfg),
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null, claimedAt: cfg.rewardType === 'points' ? now : null,
@@ -91,121 +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 ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers; const completionsFor = (memberId: string) => {
for (const m of targetMembers) { const memberTarget = targetChoreFor(cfg, memberId);
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); let list = periodCompletions.filter((c: any) => c.memberId === memberId);
let current = 0; if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
// Collaborative configs only honour the shared targetChoreId.
if (cfg.target === 'collaborative' && cfg.targetChoreId)
list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
return list;
};
// Score for one member: percent of due chores, points value, or raw count.
const scoreFor = (memberId: string): number => {
const list = completionsFor(memberId);
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
const due = dueByMember[m.id] || 0; const due = dueByMember[memberId] || 0;
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0; return due > 0 ? Math.round((list.length / due) * 100) : 0;
} else { }
current = memberCompletions.reduce((sum: number, c: any) => { return list.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') { if (cfg.type === 'count') return list.length;
current = memberCompletions.length; return 0;
} };
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue); const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
// Reconcile one member's window reward against their achieved state.
const memberReward = existingRewards.find((r: any) => r.memberId === m.id); // Returns true when a live window reward exists afterwards.
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
if (memberReward && !achieved) { const live = windowRewards.filter((r: any) => r.memberId === memberId);
if (memberReward.status !== 'claimed') { if (!achieved) {
for (const r of live) {
if (!removable(r)) continue;
try { try {
await pb.collection('rewards').delete(memberReward.id); await pb.collection('rewards').delete(r.id);
} catch {} } catch {}
windowRewards.splice(windowRewards.indexOf(r), 1);
} }
continue; return false;
} }
if (live.length > 0) return true;
if (memberReward) continue; await pb.collection('rewards').create(rewardData(memberId));
if (achieved) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true; createdReward = true;
} return true;
} };
} else if (cfg.target === 'collaborative') { // Drop stale prior-window placeholders (unclaimed, non-gated) so they
const allMemberIds = allMembers.map((m: any) => m.id); // can't pile up unseen; gated/requested/claimed rows persist by design.
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); const purgeStale = async (memberId: string) => {
let total = 0; const stale = existingRewards.filter(
if (cfg.type === 'threshold') { (r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
if (cfg.thresholdType === 'percent') { );
total = Math.round((teamCompletions.length / totalDue) * 100); for (const r of stale) {
} else {
total = teamCompletions.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') {
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 { try {
await pb.collection('rewards').delete(r.id); await pb.collection('rewards').delete(r.id);
} catch {} } catch {}
} }
} };
continue;
}
if (achieved && existingRewards.length === 0) { if (cfg.target === 'competitive') {
for (const m of allMembers) { // Winner-takes-all: a single window reward held by the top scorer.
await pb.collection('rewards').create(rewardData(m.id)); 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; createdReward = true;
} }
// A locked (claimed/requested/gated) holder keeps the crown until issued.
} }
} else if (cfg.target === 'competitive') {
const scored = allMembers.map((m: any) => {
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
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 { } else {
current = memberCompletions.reduce((sum: number, c: any) => { // 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') { } 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 {}
} }
} }
@@ -229,111 +294,13 @@ export async function progress(pb: any, famId: string) {
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
]); ]);
const assignedList = assigned;
const completionsList = completions;
const dueByMember: Record<string, number> = {};
for (const a of assignedList) {
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
}
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
let allRewards: any[] = []; let allRewards: any[] = [];
try { try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {} } catch {}
// Shared with the Rewards page client-side realtime recompute (famStore + SSE),
const result: any[] = []; // so the admin kanban and member dashboard always show identical progress.
return computeBonusProgress(configsData, members, assigned, completions, allRewards, payday, tz);
for (const cfg of configsData) {
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
const win = bonusWindow(cfg, payday, tz);
const pStart = win.from;
const pEnd = win.to;
let periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
// Count-type rewards pinned to a specific chore only count that chore's
// completions toward the target.
if (cfg.type === 'count' && cfg.targetChoreId) {
periodCompletions = periodCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
}
const progressRows: any[] = [];
if (cfg.target === 'collaborative') {
const teamCompletions = periodCompletions.filter((c: any) =>
members.some((m: any) => m.id === c.memberId)
);
let teamCurrent = 0;
if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') {
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
} else {
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === 'count') {
teamCurrent = teamCompletions.length;
}
const teamReward = cfgRewards[0];
progressRows.push({
memberId: '__team__',
memberName: 'Team Total',
memberColor: '#8b5cf6',
current: teamCurrent,
criteriaValue: cfg.criteriaValue || 0,
reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null,
state: teamReward ? teamReward.status : 'pending',
achieved: teamReward ? true : false
});
}
if (cfg.target !== 'collaborative') {
const progressMembers =
cfg.target === 'individual' && cfg.memberId
? members.filter((m: any) => m.id === cfg.memberId)
: members;
for (const m of progressMembers) {
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
const memberReward = cfgRewards.find((r: any) => r.memberId === m.id);
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 = assignedList.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;
} else if (cfg.type === 'manual') {
current = 0;
}
progressRows.push({
memberId: m.id,
memberName: m.name,
memberColor: m.color,
current,
criteriaValue: cfg.criteriaValue || 0,
reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null,
state: memberReward ? memberReward.status : 'pending',
achieved: memberReward ? true : false
});
}
}
result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd });
}
return result;
} }
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) { export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
@@ -374,9 +341,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
const pEnd = periodEnd(cfg.period, pStart); const pEnd = periodEnd(cfg.period, pStart);
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd); const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
if (periodRewards.length > 0) { if (periodRewards.length > 0) {
throw new Error( throw new Error(`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`);
`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`
);
} }
} }
} }
@@ -393,7 +358,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
memberId: m.id, memberId: m.id,
bonusConfigId: cfg.id, bonusConfigId: cfg.id,
label, label,
value: Number(cfg.rewardValue) || 0, value: getRewardValue(cfg),
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null, claimedAt: cfg.rewardType === 'points' ? now : null,
+75 -5
View File
@@ -1,3 +1,6 @@
import { sendMentionEmail } from '../email';
import { pbAdmin } from '$lib/server/pocketbase';
export type ChatActor = { export type ChatActor = {
id: string; id: string;
type: 'admin' | 'member'; type: 'admin' | 'member';
@@ -5,19 +8,33 @@ export type ChatActor = {
color: string; color: string;
}; };
// Everyone in the family (children + parents) for the @mention picker.
export async function chatMe(pb: any, famId: string, actor: ChatActor) { export async function chatMe(pb: any, famId: string, actor: ChatActor) {
return { famId, actor }; const users = await pb
.collection('users')
.getFullList({ filter: `famId = '${famId}'` })
.catch(() => []);
return {
famId,
actor,
members: users.map((m: any) => ({
id: m.id,
name: m.name || '',
color: m.color || '#6366f1',
role: m.role === 'parent' ? 'parent' : 'child'
}))
};
} }
export async function send( export async function send(
pb: any, pb: any,
famId: string, famId: string,
actor: ChatActor, actor: ChatActor,
body: { content?: string; clientId?: string } body: { content?: string; clientId?: string; siteUrl?: string }
) { ) {
const content = (body.content || '').trim(); const content = (body.content || '').trim();
if (!content) throw new Error('content required'); if (!content) throw new Error('content required');
return pb.collection('messages').create({ const record = await pb.collection('messages').create({
famId, famId,
authorType: actor.type, authorType: actor.type,
authorId: actor.id, authorId: actor.id,
@@ -27,14 +44,67 @@ export async function send(
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
clientId: body.clientId ? String(body.clientId).slice(0, 64) : '' clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
}); });
// Email any @mentioned parents (their in-app toast is client-side, via the
// same mention tokens). Fire-and-forget — never fail the message itself.
notifyMentionedParents(pb, famId, actor, record, body.siteUrl || '').catch((err: any) => {
console.error('[chat] notifyMentionedParents top-level catch:', err);
});
return record;
} }
export async function typing( // Scan the message for `@Parent Name` tokens (case-insensitive) and email every
// mentioned parent who isn't the sender, with a link back to the family site.
async function notifyMentionedParents(
pb: any, pb: any,
famId: string, famId: string,
actor: ChatActor, actor: ChatActor,
body: { typing?: boolean } message: any,
siteUrl: string
) { ) {
const content = String(message.content || '');
// Find parents whose name appears in the message content.
// getFullList for auth users may not return email, so we fetch
// each matched parent individually via pbAdmin to get the address.
const parents = await pb
.collection('users')
.getFullList({ filter: `famId = '${famId}' && role = 'parent'` })
.catch((err: any) => {
console.error('[chat] Failed to fetch parents:', err);
return [];
});
const mentioned = parents.filter((u: any) => {
if (u.id === actor.id) return false;
const name = (u.name || '').trim().toLowerCase();
return !!name && content.toLowerCase().includes('@' + name);
});
if (!mentioned.length) return;
const fam = await pb
.collection('fams')
.getOne(famId)
.catch(() => null);
// Don't send emails for the demo family.
if (fam?.slug === 'showboaters') return;
const dashboardUrl = `${siteUrl}/${fam?.slug || famId}`;
for (const parent of mentioned) {
try {
const parentRec = await pbAdmin.getOne('users', parent.id);
const parentEmail = parentRec.email;
if (!parentEmail) continue;
await sendMentionEmail({
to: parentEmail,
parentName: parent.name || 'there',
authorName: message.authorName || 'Family member',
message: content,
dashboardUrl
});
} catch (err) {
console.error(`[chat] Failed to send mention email to ${parent.name}:`, err);
}
}
}
export async function typing(pb: any, famId: string, actor: ChatActor, body: { typing?: boolean }) {
const existing = await pb.collection('chat_typing').getFullList({ const existing = await pb.collection('chat_typing').getFullList({
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'` filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
}); });
+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 };
} }
+34 -5
View File
@@ -69,7 +69,16 @@ export async function patchFam(pb: any, famId: string, body: Record<string, unkn
export async function getProfile(pb: any, famId: string, userId: string) { export async function getProfile(pb: any, famId: string, userId: string) {
const rec = await pb.collection('users').getOne(userId); const rec = await pb.collection('users').getOne(userId);
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' }; return {
id: rec.id,
name: rec.name || '',
color: rec.color || '#6366f1',
pattern: rec.pattern || '',
themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '',
partyEmoji: rec.partyEmoji || '',
email: rec.email || ''
};
} }
export async function updateProfile( export async function updateProfile(
@@ -81,9 +90,23 @@ export async function updateProfile(
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
if (data.name) patch.name = data.name; if (data.name) patch.name = data.name;
if (data.color) patch.color = data.color; if (data.color) patch.color = data.color;
if (data.pattern !== undefined) patch.pattern = data.pattern;
if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
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 { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' }; return {
id: rec.id,
name: rec.name || '',
color: rec.color || '#6366f1',
pattern: rec.pattern || '',
themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '',
partyEmoji: rec.partyEmoji || '',
email: rec.email || ''
};
} }
export async function weeklySummary(pb: any, famId: string) { export async function weeklySummary(pb: any, famId: string) {
@@ -150,8 +173,11 @@ export async function weeklySummary(pb: any, famId: string) {
let bonusMoney = 0; let bonusMoney = 0;
try { try {
// Incoming cash counts too (unclaimed/requested, e.g. achieved but
// not yet approved pocket money) — this is the "earned this week"
// figure, not just banked cash.
const cashRewards = await pb.collection('rewards').getFullList({ const cashRewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'`
}); });
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
} catch {} } catch {}
@@ -180,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,
@@ -254,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 {}
+16 -8
View File
@@ -48,16 +48,18 @@ export function createServices(
crudSvc.create(pb, resource, famId, data), crudSvc.create(pb, resource, famId, data),
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) => update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
crudSvc.update(pb, resource, famId, id, data), crudSvc.update(pb, resource, famId, id, data),
remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id) remove: (resource: string, famId: string, id: string) =>
crudSvc.remove(pb, resource, famId, id)
}, },
chores: { chores: {
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid), myChores: (famId: string) => choresSvc.myChores(pb, famId, uid),
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) => completionsSvc.revoke(pb, famId, completionId) revoke: (famId: string, completionId: string) =>
completionsSvc.revoke(pb, famId, completionId)
}, },
rewards: { rewards: {
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id), claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
@@ -77,9 +79,12 @@ 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, {
destroy: (famId: string, configId: string) => status: 'completed',
pb.collection('bonus_configs').delete(configId) completedAt: new Date().toISOString().slice(0, 10),
completedBy: 'admin'
}),
destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId)
}, },
settings: { settings: {
get: (famId: string) => settingsSvc.getSettings(pb, famId), get: (famId: string) => settingsSvc.getSettings(pb, famId),
@@ -88,8 +93,11 @@ export function createServices(
}, },
chat: { chat: {
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor), me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) => send: (
chatSvc.send(pb, famId, actor, body), famId: string,
actor: chatSvc.ChatActor,
body: { content?: string; clientId?: string; siteUrl?: string }
) => chatSvc.send(pb, famId, actor, body),
typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) => typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) =>
chatSvc.typing(pb, famId, actor, body) chatSvc.typing(pb, famId, actor, body)
}, },
+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);
} }
+50 -6
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) {
+4
View File
@@ -5,4 +5,8 @@ export interface SessionUser {
role: 'parent' | 'child'; role: 'parent' | 'child';
famId: string; famId: string;
color?: string; color?: string;
pattern?: string;
themeSize?: 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;
} }
+32 -4
View File
@@ -1,5 +1,7 @@
import { pb } from '$lib/pocketbase'; import { pb } from '$lib/pocketbase';
import type { ChatMessage, TypingRow } from '$lib/types'; import type { ChatMessage, TypingRow, MentionMember } from '$lib/types';
import { addAutoDismissNotice } from './notices.svelte';
import { famStore } from './fam.svelte';
interface ChatInit { interface ChatInit {
famId: string; famId: string;
@@ -8,7 +10,7 @@ interface ChatInit {
actorName: string; actorName: string;
actorColor: string; actorColor: string;
pbToken?: string; pbToken?: string;
members?: MentionMember[];
} }
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a // Client id for optimistic chat messages. `crypto.randomUUID()` requires a
@@ -34,6 +36,7 @@ class ChatStore {
actorName = $state(''); actorName = $state('');
actorColor = $state(''); actorColor = $state('');
pbToken = $state(''); pbToken = $state('');
members = $state<MentionMember[]>([]);
private unsubs: (() => void)[] = []; private unsubs: (() => void)[] = [];
private destroyed = false; private destroyed = false;
@@ -80,6 +83,7 @@ class ChatStore {
this.actorName = opts.actorName; this.actorName = opts.actorName;
this.actorColor = opts.actorColor; this.actorColor = opts.actorColor;
this.pbToken = opts.pbToken || ''; this.pbToken = opts.pbToken || '';
this.members = opts.members || [];
this.messages = []; this.messages = [];
this.typing = {}; this.typing = {};
this.unread = 0; this.unread = 0;
@@ -116,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')
@@ -125,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]);
} }
@@ -160,9 +170,26 @@ class ChatStore {
if (record.authorId !== this.actorId) { if (record.authorId !== this.actorId) {
if (!this.open) this.unread++; if (!this.open) this.unread++;
this.playDunk(); this.playDunk();
if (this.notifyIfMentioned(record) && this.open) this.unread++;
} }
} }
// Toast the current user when a message @mentions them by name.
// Returns true if the user was mentioned.
private notifyIfMentioned(record: ChatMessage): boolean {
const lower = (record.content || '').toLowerCase();
const me = this.members.find((m) => m.id === this.actorId);
if (!me || !me.name) return false;
if (!lower.includes('@' + me.name.toLowerCase())) return false;
addAutoDismissNotice({
type: 'info',
title: 'You were mentioned 👋',
message: `${record.authorName || 'Someone'}: ${record.content}`,
dismissible: true
});
return true;
}
private onTyping(action: string, record: TypingRow) { private onTyping(action: string, record: TypingRow) {
if (record.famId !== this.famId) return; if (record.famId !== this.famId) return;
if (action === 'delete') { if (action === 'delete') {
@@ -310,6 +337,7 @@ class ChatStore {
for (const t of this.typingTimers.values()) clearTimeout(t); for (const t of this.typingTimers.values()) clearTimeout(t);
this.typingTimers.clear(); this.typingTimers.clear();
this.initialized = false; this.initialized = false;
this.members = [];
} }
} }
+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();
+14
View File
@@ -0,0 +1,14 @@
// Live theme draft — the PatternPicker writes here the moment a tile (or a
// colour swatch) is clicked, so the fam layout can preview the background
// instantly. `null` means "no draft yet — use the saved value from session".
export const themeDraft = $state<{
pattern: string | null;
color: string | null;
size: number | null;
opacity: number | null;
}>({
pattern: null,
color: null,
size: null,
opacity: null
});
+18 -2
View File
@@ -16,7 +16,15 @@ import {
Leaf, Leaf,
Heart, Heart,
Gift, Gift,
Timer Timer,
PawPrint,
Guitar,
Medal,
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> = {
@@ -37,7 +45,15 @@ export const ICON_MAP: Record<string, any> = {
Leaf, Leaf,
Heart, Heart,
Gift, Gift,
Timer Timer,
PawPrint,
Guitar,
Medal,
Wrench,
PenLine,
SprayCan,
Droplets,
CookingPot
}; };
export const ICON_NAMES = Object.keys(ICON_MAP); export const ICON_NAMES = Object.keys(ICON_MAP);
+991
View File
@@ -0,0 +1,991 @@
/* Generated from css-patterns.css (see scripts/gen-theme-patterns.mjs).
57 unique patterns (3 duplicates removed).
Colors are overridden per-user via --c1..--c4 set inline on the element
(hsl shades of the chosen swatch). */
html .app-shell.pattern-p1,
.pattern-p1 {
--s: 84px; /* control the size*/
--c1: #f2f2f2;
--c2: #cdcbcc;
--c3: #999999;
--_g: 0 120deg,#0000 0;
background:
conic-gradient( at calc(250%/3) calc(100%/3),var(--c3) var(--_g)),
conic-gradient(from -120deg at calc( 50%/3) calc(100%/3),var(--c2) var(--_g)),
conic-gradient(from 120deg at calc(100%/3) calc(250%/3),var(--c1) var(--_g)),
conic-gradient(from 120deg at calc(200%/3) calc(250%/3),var(--c1) var(--_g)),
conic-gradient(from -180deg at calc(100%/3) 50%,var(--c2) 60deg,var(--c1) var(--_g)),
conic-gradient(from 60deg at calc(200%/3) 50%,var(--c1) 60deg,var(--c3) var(--_g)),
conic-gradient(from -60deg at 50% calc(100%/3),var(--c1) 120deg,var(--c2) 0 240deg,var(--c3) 0);
background-size: calc(var(--s)*sqrt(3)) var(--s);
}
html .app-shell.pattern-p2,
.pattern-p2 {
--s: 200px; /* control the size*/
--c1: #1d1d1d;
--c2: #4e4f51;
--c3: #3c3c3c;
background:
repeating-conic-gradient(from 30deg,#0000 0 120deg,var(--c3) 0 50%)
calc(var(--s)/2) calc(var(--s)*tan(30deg)/2),
repeating-conic-gradient(from 30deg,var(--c1) 0 60deg,var(--c2) 0 120deg,var(--c3) 0 50%);
background-size: var(--s) calc(var(--s)*tan(30deg));
}
html .app-shell.pattern-p3,
.pattern-p3 {
--s: 64px; /* control the size*/
--c1: #c02942;
--c2: #53777a;
--c3: #ecd078;
--c4: #d95b43;
background:
radial-gradient(var(--c1) 24%,#0000 25%),
radial-gradient(var(--c2) 30%,#0000 32%) calc(var(--s)/2) calc(var(--s)/2),
repeating-conic-gradient(from 30deg,var(--c3) 0 30deg,var(--c4) 0 25%);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p4,
.pattern-p4 {
--s: 100px; /* control the size*/
--c1: #fff0e5;
--c2: #025d8c;
--c3: #e1642a;
--_g: 50%,#0000 37%,var(--c1) 39% 70%,#0000 72%;
--_t: 50%,var(--c2) 40deg,var(--c3) 0 140deg,var(--c2) 0 180deg,#0000 0;
--_s: 47% 50% at;
background:
radial-gradient(var(--_s) -10% var(--_g)) 0 calc(var(--s)/2),
radial-gradient(var(--_s) -10% var(--_g)) calc(var(--s)/2) 0,
radial-gradient(var(--_s) 110% var(--_g)),
radial-gradient(var(--_s) 110% var(--_g)) calc(var(--s)/2) calc(var(--s)/2),
conic-gradient(from 0deg at 55% var(--_t)) calc(var(--s)/4) 0,
conic-gradient(from 180deg at 45% var(--_t)) calc(var(--s)/4) 0,
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p5,
.pattern-p5 {
--s: 150px; /* control the size*/
--c1: #f7d2a1;
--c2: #05057e;
--_g:
var(--c1) 0% 5% ,var(--c2) 6% 15%,var(--c1) 16% 25%,var(--c2) 26% 35%,var(--c1) 36% 45%,
var(--c2) 46% 55%,var(--c1) 56% 65%,var(--c2) 66% 75%,var(--c1) 76% 85%,var(--c2) 86% 95%,
#0000 96%;
background:
radial-gradient(50% 50% at 100% 0,var(--_g)),
radial-gradient(50% 50% at 0 100%,var(--_g)),
radial-gradient(50% 50%,var(--_g)),
radial-gradient(50% 50%,var(--_g)) calc(var(--s)/2) calc(var(--s)/2)
var(--c1);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p6,
.pattern-p6 {
--s: 120px; /* control the size*/
--c1: #fa6900;
--c2: #d95b43;
--c3: #ecd078;
background:
conic-gradient(from 150deg at 50% 33%,#0000,var(--c1) .5deg 60deg,#0000 60.5deg)
calc(var(--s)/2) calc(var(--s)/sqrt(2)),
conic-gradient(from -30deg at 50% 66%,#0000,var(--c2) .5deg 60deg,var(--c3) 60.5deg);
background-size: var(--s) calc(.5*var(--s)/tan(30deg));
}
html .app-shell.pattern-p7,
.pattern-p7 {
--s: 100px; /* control the size*/
--c1: #4ecdc4;
--c2: #556270;
background:
linear-gradient(atan(-.5),var(--c1) 33%,var(--c2) 33.5% 66.5%,var(--c1) 67%)
0/var(--s) var(--s);
}
html .app-shell.pattern-p8,
.pattern-p8 {
--s: 100px; /* control the size*/
--c1: #eceddc;
--c2: #29ab87;
--_g: var(--c1) 90deg,#0000 90.5deg;
background:
conic-gradient(from -45deg,var(--_g)),
conic-gradient(from 135deg,var(--_g)) calc(var(--s)/2) 0,
var(--c2);
background-size: var(--s) var(--s)
}
html .app-shell.pattern-p9,
.pattern-p9 {
--s: 150px; /* control the size*/
--c1: #ff847c;
--c2: #e84a5f;
--c3: #fecea8;
--c4: #99b898;
background:
conic-gradient(from 45deg at 75% 75%, var(--c3) 90deg,var(--c1) 0 180deg,#0000 0),
conic-gradient(from -45deg at 25% 25%, var(--c3) 90deg,#0000 0),
conic-gradient(from -45deg at 50% 100%,#0000 180deg,var(--c3) 0),
conic-gradient(from -45deg,var(--c1) 90deg, var(--c2) 0 225deg,var(--c4) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p10,
.pattern-p10 {
--s: 222px; /* control the size*/
--c1: #7f727b;
--c2: #d6b4c2;
--c3: #baa0ab;
--_g: var(--c1) 10%,var(--c2) 10.5% 19%,#0000 19.5% 80.5%,var(--c2) 81% 89.5%,var(--c3) 90%;
--_c: from -90deg at 37.5% 50%,#0000 75%;
--_l1: linear-gradient(145deg,var(--_g));
--_l2: linear-gradient( 35deg,var(--_g));
background:
var(--_l1), var(--_l1) calc(var(--s)/2) var(--s),
var(--_l2), var(--_l2) calc(var(--s)/2) var(--s),
conic-gradient(var(--_c),var(--c1) 0) calc(var(--s)/8) 0,
conic-gradient(var(--_c),var(--c3) 0) calc(var(--s)/2) 0,
linear-gradient(90deg,var(--c3) 38%,var(--c1) 0 50%,var(--c3) 0 62%,var(--c1) 0);
background-size: var(--s) calc(2*var(--s)/3);
}
html .app-shell.pattern-p11,
.pattern-p11 {
--s: 20px; /* control the size*/
--c1: #c02942;
--c2: #53777a;
--c3: #ecd078;
--g: var(--s); /* gap between lines */
background:
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 270deg,var(--c1) 0) calc(var(--s) + var(--g)) 0,
linear-gradient(var(--c2) var(--s),#0000 0) 0 var(--g),
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 90deg,var(--c2) 0 180deg, var(--c1) 0),
var(--c3);
background-size: calc(2*(var(--s) + var(--g))) calc(2*(var(--s) + var(--g)));
}
html .app-shell.pattern-p12,
.pattern-p12 {
--s: 200px; /* control the size*/
--c1: #ffffff;
--c2: #1095c1;
--_g: #0000 8%,var(--c1) 0 17%,#0000 0 58%;
background:
linear-gradient(135deg,#0000 20.5%,var(--c1) 0 29.5%,#0000 0) 0 calc(var(--s)/4),
linear-gradient( 45deg,var(--_g)) calc(var(--s)/2) 0,
linear-gradient(135deg,var(--_g),var(--c1) 0 67%,#0000 0),
linear-gradient( 45deg,var(--_g),var(--c1) 0 67%,#0000 0 83%,var(--c1) 0 92%,#0000 0),
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p13,
.pattern-p13 {
--s: 64px; /* control the size*/
--c1: #e0e4cc;
--c2: #69d2e7;
--_g: 90deg,#0000 0;
background:
conic-gradient(from 135deg,var(--c1) var(--_g)) var(--s) calc(var(--s)/2),
conic-gradient(from 135deg,var(--c2) var(--_g)),
conic-gradient(from 135deg at 50% 0,var(--c1) var(--_g)) var(--c2);
background-size: calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p14,
.pattern-p14 {
--s: 64px; /* control the size*/
--c1: #eb6841;
--c2: #edc951;
background:
conic-gradient(from -45deg,var(--c1) 90deg,#0000 0 180deg,var(--c2) 0 270deg,#0000 0)
0 calc(var(--s)/2)/var(--s) var(--s),
conic-gradient(from 135deg at 50% 0,var(--c1) 90deg,var(--c2) 0)
0 0/calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p15,
.pattern-p15 {
--s: 36px; /* control the size*/
--c1: #b62f31;
--c2: #ecdacb;
--c3: #8e1f08;
--_g: calc(2*var(--s)*1.732) calc(2*var(--s))
conic-gradient(from 60deg at 62.5% 50%,var(--c3) 60deg,#0000 0);
background:
calc( 2.598*var(--s)/2) calc(var(--s)/ 2)/var(--_g),
calc(-0.866*var(--s)/2) calc(var(--s)/-2)/var(--_g),
repeating-conic-gradient(var(--c2) 0 90deg,#0000 0 180deg)
0 0/calc(2*var(--s)*1.732) calc(2*var(--s)),
repeating-conic-gradient(from 60deg,var(--c1) 0 60deg,var(--c2) 0 180deg)
0 0/calc(var(--s)*1.732) var(--s);
}
html .app-shell.pattern-p16,
.pattern-p16 {
--s: 125px; /* control the size*/
--c1: #f8ca00;
--c2: #bd1550;
--_g: var(--c1) 90deg,var(--c2) 0 135deg,#0000 0;
background:
conic-gradient(from -45deg at calc(100%/3) calc(100%/3) ,var(--c1) 90deg,#0000 0 ),
conic-gradient(from -135deg at calc(100%/3) calc(2*100%/3),var(--_g)),
conic-gradient(from 135deg at calc(2*100%/3) calc(2*100%/3),var(--_g)),
conic-gradient(from 45deg at calc(2*100%/3) calc(100%/3) ,var(--_g),var(--c1) 0 225deg,var(--c2) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p17,
.pattern-p17 {
--s: 120px; /* control the size*/
--c1: #72e21f;
--c2: #044012;
--_c: var(--c1) 25%,#0000 0;
--_g1: conic-gradient(at 62.5% 12.5%,var(--_c));
--_g2: conic-gradient(at 87.5% 62.5%,var(--_c));
--_g3: conic-gradient(at 25% 12.5%,var(--_c));
background:
var(--_g1) calc( var(--s)/-8) calc(var(--s)/2),var(--_g1) calc(-3*var(--s)/8) calc(var(--s)/4),
var(--_g2) calc(3*var(--s)/8) calc(var(--s)/4),var(--_g2) calc( var(--s)/-8) 0,
var(--_g3) 0 calc(var(--s)/-4),var(--_g3) calc(var(--s)/-4) 0,
conic-gradient(at 87.5% 87.5%,var(--_c)) calc(var(--s)/8) 0
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p18,
.pattern-p18 {
--s: 170px; /* control the size*/
--c1: #3b8686;
--c2: #cff09e;
--_c: #0000 75%,var(--c1) 0;
--_g1: conic-gradient(at 20.71% 50%,var(--_c));
--_g2: conic-gradient(at 50% 20.71%,var(--_c));
background:
var(--_g1) calc(var(--s)/ 6.828) calc(var(--s)/2),var(--_g1) calc(var(--s)/-2.828) 0,
conic-gradient(from 45deg at 29.29% 29.29%,var(--_c)) calc(var(--s)/-6.828) 0,
var(--_g2) calc(var(--s)/2) calc(var(--s)/-2.828),var(--_g2) 0 calc(var(--s)/ 6.828),
conic-gradient(from -135deg at 29.29% 70.71%,var(--_c)) calc(var(--s)/-6.828) 0,
var(--c2);
background-size: var(--s) var(--s), var(--s) var(--s), calc(var(--s)/2) calc(var(--s)/2);
}
html .app-shell.pattern-p19,
.pattern-p19 {
--s: 76px; /* control the size*/
--c1: #f6d86b;
--c2: #f10c49;
background:
conic-gradient(
var(--c2) atan(2), var(--c1) 0 calc(180deg - atan(2)),
var(--c2) 0 180deg,var(--c1) 0 calc(180deg + atan(2)),
var(--c2) 0 calc(360deg - atan(2)),var(--c1) 0);
background-size: calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p20,
.pattern-p20 {
--s: 40px; /* control the size*/
--c1: #c0d860;
--c2: #604848;
--_c: #0000 75%,var(--c1) 0;
--_g1: conic-gradient(at 10% 50%,var(--_c));
--_g2: conic-gradient(at 50% 10%,var(--_c));
background:
var(--_g1),
var(--_g1) calc(1*var(--s)) calc(3*var(--s)),
var(--_g1) calc(2*var(--s)) calc(1*var(--s)),
var(--_g1) calc(3*var(--s)) calc(4*var(--s)),
var(--_g1) calc(4*var(--s)) calc(2*var(--s)),
var(--_g2) 0 calc(4*var(--s)),
var(--_g2) calc(1*var(--s)) calc(2*var(--s)),
var(--_g2) calc(2*var(--s)) 0,
var(--_g2) calc(3*var(--s)) calc(3*var(--s)),
var(--_g2) calc(4*var(--s)) calc(1*var(--s)),
var(--c2);
background-size: calc(5*var(--s)) calc(5*var(--s));
}
html .app-shell.pattern-p21,
.pattern-p21 {
--s: 65px; /* control the size*/
--c1: #dadee1;
--c2: #4a99b4;
--c3: #9cceb5;
--_c: 75%,var(--c3) 52.72deg,#0000 0;
--_g1: conic-gradient(from -116.36deg at 25% var(--_c));
--_g2: conic-gradient(from 63.43deg at 75% var(--_c));
background:
var(--_g1), var(--_g1) calc(3*var(--s)) calc(var(--s)/2),
var(--_g2), var(--_g2) calc(3*var(--s)) calc(var(--s)/2),
conic-gradient(
var(--c2) 63.43deg ,var(--c1) 0 116.36deg,
var(--c2) 0 180deg ,var(--c1) 0 243.43deg,
var(--c2) 0 296.15deg,var(--c1) 0);
background-size: calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p22,
.pattern-p22 {
--s: 82px; /* control the size*/
--c1: #b2b2b2;
--c2: #ffffff;
--c3: #d9d9d9;
--_g: var(--c3) 0 120deg,#0000 0;
background:
conic-gradient(from -60deg at 50% calc(100%/3),var(--_g)),
conic-gradient(from 120deg at 50% calc(200%/3),var(--_g)),
conic-gradient(from 60deg at calc(200%/3),var(--c3) 60deg,var(--c2) 0 120deg,#0000 0),
conic-gradient(from 180deg at calc(100%/3),var(--c1) 60deg,var(--_g)),
linear-gradient(90deg,var(--c1) calc(100%/6),var(--c2) 0 50%,
var(--c1) 0 calc(500%/6),var(--c2) 0);
background-size: calc(1.732*var(--s)) var(--s);
}
html .app-shell.pattern-p23,
.pattern-p23 {
--s: 50px; /* control the size*/
--c1: #0b2e59;
--c2: #0d6759;
background:
conic-gradient(at 25% 25%,#0000 75%,var(--c1) 0) var(--s) var(--s),
repeating-conic-gradient(at 25% 25%,var(--c1) 0 25%,var(--c2) 0 50%);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p24,
.pattern-p24 {
--s: 100px; /* control the size*/
--c1: #1bb0ce;
--c2: #dad6ca;
--_g1:
var(--c1) calc(25%/3) ,#0000 0 calc(50%/3),
var(--c1) 0 25% ,#0000 0 75%,
var(--c1) 0 calc(250%/3),#0000 0 calc(275%/3),
var(--c1) 0;
--_g2:
#0000 calc(25%/3) ,var(--c1) 0 calc(50%/3),
#0000 0 calc(250%/3),var(--c1) 0 calc(275%/3),
#0000 0;
background:
linear-gradient( 45deg,var(--_g2)),linear-gradient( 45deg,var(--_g1)),
linear-gradient(-45deg,var(--_g2)),linear-gradient(-45deg,var(--_g1))
var(--c2); /* second color */
background-position: 0 0,var(--s) var(--s);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p25,
.pattern-p25 {
--s: 100px; /* control the size*/
--c1: #c3ccaf;
--c2: #67434f;
--_s: calc(2*var(--s)) calc(2*var(--s));
--_g: var(--_s) conic-gradient(at 40% 40%,#0000 75%,var(--c1) 0);
--_p: var(--_s) conic-gradient(at 20% 20%,#0000 75%,var(--c2) 0);
background:
calc( .9*var(--s)) calc( .9*var(--s))/var(--_p),
calc(-.1*var(--s)) calc(-.1*var(--s))/var(--_p),
calc( .7*var(--s)) calc( .7*var(--s))/var(--_g),
calc(-.3*var(--s)) calc(-.3*var(--s))/var(--_g),
conic-gradient(from 90deg at 20% 20%,var(--c2) 25%,var(--c1) 0)
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p26,
.pattern-p26 {
--s: 106px; /* control the size*/
--c1: #eafde6;
--c2: #519548;
background:
conic-gradient(from 30deg at 87.5% 75%,var(--c1) 60deg,var(--c2) 0 120deg,#0000 0) 0 calc(.2165*var(--s)),
conic-gradient(from -90deg at 50% 25%,var(--c2) 60deg,var(--c1) 0 180deg,#0000 0),
conic-gradient(from 90deg at 50% 75%,var(--c2) 120deg,var(--c1) 0 180deg,#0000 0),
conic-gradient(from -30deg at 87.5% 50%,var(--c2) 120deg,var(--c1) 0 240deg,#0000 0),
conic-gradient(from 90deg at 37.5% 50%,var(--c2) 120deg,var(--c1) 0 180deg,var(--c2) 0 240deg,var(--c1) 0);
background-size: var(--s) calc(.866*var(--s));
}
html .app-shell.pattern-p27,
.pattern-p27 {
--s: 60px; /* control the size*/
--c1: #b09f79;
--c2: #476074;
--_g: #0000 83%,var(--c1) 85% 99%,#0000 101%;
background:
radial-gradient(27% 29% at right ,var(--_g)) calc(var(--s)/ 2) var(--s),
radial-gradient(27% 29% at left ,var(--_g)) calc(var(--s)/-2) var(--s),
radial-gradient(29% 27% at top ,var(--_g)) 0 calc(var(--s)/ 2),
radial-gradient(29% 27% at bottom,var(--_g)) 0 calc(var(--s)/-2)
var(--c2);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p28,
.pattern-p28 {
--s: 80px; /* control the size*/
--c1: #5e8c6a;
--c2: #bfb35a;
--_s: calc(2*var(--s)) calc(2*var(--s));
--_g: 35.36% 35.36% at;
--_c: #0000 66%,var(--c2) 68% 70%,#0000 72%;
background:
radial-gradient(var(--_g) 100% 25%,var(--_c)) var(--s) var(--s)/var(--_s),
radial-gradient(var(--_g) 0 75%,var(--_c)) var(--s) var(--s)/var(--_s),
radial-gradient(var(--_g) 100% 25%,var(--_c)) 0 0/var(--_s),
radial-gradient(var(--_g) 0 75%,var(--_c)) 0 0/var(--_s),
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
radial-gradient(var(--_c)) 0 calc(var(--s)/2)/var(--s) var(--s)
var(--c1);
}
html .app-shell.pattern-p29,
.pattern-p29 {
--s: 150px; /* control the size*/
--c1: #ccbf82;
--c2: #604848;
--_g: var(--c1) 6.1%,var(--c2) 6.4% 18.6%,var(--c1) 18.9% 31.1%,
var(--c2) 31.4% 43.6%,var(--c1) 43.9% 56.1%,var(--c2) 56.4% 68.6%,#0000 68.9%;
background:
radial-gradient(var(--s) at 100% 0 ,var(--_g)),
radial-gradient(var(--s) at 0 0 ,var(--_g)),
radial-gradient(var(--s) at 0 100%,var(--_g)),
radial-gradient(var(--s) at 100% 100%,var(--_g)) var(--c1);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p30,
.pattern-p30 {
--s: 150px; /* control the size*/
--c1: #046d8b;
--c2: #2fb8ac;
--_g: #0000 90deg,var(--c1) 0;
background:
conic-gradient(from 116.56deg at calc(100%/3) 0 ,var(--_g)),
conic-gradient(from -63.44deg at calc(200%/3) 100%,var(--_g))
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p31,
.pattern-p31 {
--s: 60px; /* control the size*/
--c1: #ffdc56;
--c2: #fe6601;
--c3: #803201;
--_s: calc(2*var(--s)) calc(2*var(--s));
--_g1: var(--_s) conic-gradient(at calc(500%/6) 50%,var(--c3) 25%,#0000 0);
--_g2: var(--_s) conic-gradient(at calc(200%/3) 50%,var(--c2) 25%,#0000 0);
background:
var(--s) var(--s)/var(--_g1),0 0/var(--_g1),
var(--s) var(--s)/var(--_g2),0 0/var(--_g2),
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
linear-gradient(var(--c1) calc(100%/3),var(--c2) 0 calc(200%/3),var(--c3) 0)
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p32,
.pattern-p32 {
--s: 50px; /* control the size*/
--c1: #6963bb;
--c2: #99ffc0;
--c3: #ffd6d6;
--_g: conic-gradient(at 50% 25%,#0000 75%,var(--c1) 0);
background:
var(--_g),var(--_g) var(--s) var(--s),
var(--_g) calc(2*var(--s)) calc(2*var(--s)),
var(--_g) calc(3*var(--s)) calc(3*var(--s)),
repeating-linear-gradient(135deg,var(--c2) 0 12.5%,var(--c3) 0 25%);
background-size: calc(4*var(--s)) calc(4*var(--s))
}
html .app-shell.pattern-p33,
.pattern-p33 {
--s: 100px; /* control the size*/
--c1: #668284;
--c2: #b6d8c0;
--c3: #b9d7d9;
--_g: #0000, var(--c1) 1deg 30deg,var(--c2) 31deg 89deg,var(--c1) 90deg 119deg,#0000 120deg;
background:
conic-gradient(from -60deg at 50% 28.86%,var(--_g)),
conic-gradient(from 30deg at 71.14% 50%,var(--_g)),
conic-gradient(from 120deg at 50% 71.14%,var(--_g)),
conic-gradient(from 210deg at 28.86% 50%,var(--_g))
var(--c3);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p34,
.pattern-p34 {
--s: 100px; /* control the size*/
--c1: #fcd036;
--c2: #987f69;
--_g: var(--c1) 0 100%,#0000 102%;
background:
conic-gradient(#0000 75%,var(--_g)) calc(var(--s)/4) calc(var(--s)/4),
radial-gradient(65% 65% at 50% -50%,var(--_g)),
radial-gradient(65% 65% at -50% 50%,var(--_g)),
radial-gradient(65% 65% at 50% 150%,var(--_g)),
radial-gradient(65% 65% at 150% 50%,var(--_g))
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p35,
.pattern-p35 {
--s: 76px; /* control the size*/
--c1: #1a144a;
--c2: #2eb044;
--_g: conic-gradient(var(--c1) 25%,#0000 0) 0 0;
background:
var(--_g)/calc(2*var(--s)) calc(var(--s)/9.5),
var(--_g)/calc(var(--s)/9.5) calc(2*var(--s)),
repeating-conic-gradient(#0000 0 25%,var(--c1) 0 50%)
var(--s) 0 /calc(2*var(--s)) calc(2*var(--s)),
radial-gradient(50% 50%,var(--c2) 98%,var(--c1))
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p36,
.pattern-p36 {
--s: 70px; /* control the size*/
--c1: #6b5344;
--c2: #f8ecc9;
--_l: #0000 46%,var(--c1) 47% 53%,#0000 54%;
background:
radial-gradient(100% 100% at 100% 100%,var(--_l)) var(--s) var(--s),
radial-gradient(100% 100% at 0 0 ,var(--_l)) var(--s) var(--s),
radial-gradient(100% 100%,#0000 22%,var(--c1) 23% 29%, #0000 30% 34%,var(--c1) 35% 41%,#0000 42%),
var(--c2);
background-size: calc(var(--s)*2) calc(var(--s)*2);
}
html .app-shell.pattern-p37,
.pattern-p37 {
--s: 80px; /* control the size*/
--c1: #542437;
--c2: #c02942;
--_g:
#0000 calc(-650%/13) calc(50%/13),var(--c1) 0 calc(100%/13),
#0000 0 calc(150%/13),var(--c1) 0 calc(200%/13),
#0000 0 calc(250%/13),var(--c1) 0 calc(300%/13);
--_g0: repeating-linear-gradient( 45deg,var(--_g));
--_g1: repeating-linear-gradient(-45deg,var(--_g));
background:
var(--_g0),var(--_g0) var(--s) var(--s),
var(--_g1),var(--_g1) var(--s) var(--s) var(--c2);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p38,
.pattern-p38 {
--s: 56px; /* control the size*/
--c1: #3fb8af;
--c2: #ff9e9d;
--_c1: var(--c1) 99%,#0000 101%;
--_c2: var(--c2) 99%,#0000 101%;
--r:calc(var(--s)*.866);
--g0:radial-gradient(var(--s),var(--_c1));
--g1:radial-gradient(var(--s),var(--_c2));
--f:radial-gradient(var(--s) at calc(100% + var(--r)) 50%,var(--_c1));
--p:radial-gradient(var(--s) at 100% 50%,var(--_c2));
background:
var(--f) 0 calc(-5*var(--s)/2),
var(--f) calc(-2*var(--r)) calc(var(--s)/2),
var(--p) 0 calc(-2*var(--s)),
var(--g0) var(--r) calc(-5*var(--s)/2),
var(--g1) var(--r) calc( 5*var(--s)/2),
radial-gradient(var(--s) at 100% 100%,var(--_c1)) 0 calc(-1*var(--s)),
radial-gradient(var(--s) at 0% 50% ,var(--_c1)) 0 calc(-4*var(--s)),
var(--g1) calc(-1*var(--r)) calc(-7*var(--s)/2),
var(--g0) calc(-1*var(--r)) calc(-5*var(--s)/2),
var(--p) calc(-2*var(--r)) var(--s),
var(--g0) calc(-1*var(--r)) calc(var(--s)/ 2),
var(--g1) calc(-1*var(--r)) calc(var(--s)/-2),
var(--g0) 0 calc(-1*var(--s)),
var(--g1) var(--r) calc(var(--s)/-2),
var(--g0) var(--r) calc(var(--s)/ 2)
var(--c2);
background-size: calc(4*var(--r)) calc(6*var(--s));
}
html .app-shell.pattern-p39,
.pattern-p39 {
--s: 50px; /* control the size*/
--c1: #cff09e;
--c2: #0b486b;
--_g: radial-gradient(var(--c2) 49%,#0000 50%);
background:
var(--_g) calc(var(--s)/-2) calc(var(--s)/2),
repeating-conic-gradient(from 45deg,var(--c1) 0 25%,#0000 0 50%)
calc(var(--s)/2) calc(var(--s)/2),
var(--_g),var(--_g) var(--s) var(--s) var(--c1);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p40,
.pattern-p40 {
--s: 75px; /* control the size*/
--c1: #1fffa9;
--c2: #a31e39;
--c3: #31570e;
--_c:#0000,var(--c1) 1deg 79deg,#0000 81deg;
--g0:conic-gradient(from 140deg at 50% 87.5% ,var(--_c));
--g1:conic-gradient(from 140deg at 50% 81.25%,var(--_c));
--g2:conic-gradient(from 140deg at 50% 75% ,var(--_c));
--g3:conic-gradient(at 10% 20%,#0000 75%,var(--c1) 0);
background:
var(--g0) 0 calc(var(--s)/-4),var(--g0) var(--s) calc(3*var(--s)/4),
var(--g1) ,var(--g1) var(--s) var(--s),
var(--g2) 0 calc(var(--s)/ 4),var(--g2) var(--s) calc(5*var(--s)/4),
var(--g3) calc( var(--s)/-10) var(--s),
var(--g3) calc(9*var(--s)/10) calc(2*var(--s)),
repeating-conic-gradient(from 45deg,var(--c2) 0 25%,var(--c3) 0 50%);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p41,
.pattern-p41 {
--s: 150px; /* control the size*/
--c1: #5e412f;
--c2: #fcebb6;
background:
linear-gradient(135deg,#0000 18.75%,var(--c1) 0 31.25%,#0000 0),
repeating-linear-gradient(45deg,var(--c1) -6.25% 6.25%,var(--c2) 0 18.75%);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p42,
.pattern-p42 {
--s: 120px; /* control the size*/
--c1: #1a2030;
--c2: #0f9177;
--c3: #fdebad;
--c4: #d34434;
--c5: #b5d999;
--_g: radial-gradient(#0000 70%,var(--c1) 71%);
background:
var(--_g),var(--_g) calc(var(--s)/2) calc(var(--s)/2),
conic-gradient(var(--c2) 25%,var(--c3) 0 50%,var(--c4) 0 75%,var(--c5) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p43,
.pattern-p43 {
--s: 34px; /* control the size*/
--c1: #ecbe13;
--c2: #309292;
--_g: radial-gradient(calc(var(--s)/2),var(--c1) 97%,#0000);
background:
var(--_g),var(--_g) calc(2*var(--s)) calc(2*var(--s)),
repeating-conic-gradient(from 45deg,#0000 0 25%,var(--c2) 0 50%) calc(-.707*var(--s)) calc(-.707*var(--s)),
repeating-linear-gradient(135deg,var(--c1) calc(var(--s)/-2) calc(var(--s)/2),var(--c2) 0 calc(2.328*var(--s)));
background-size: calc(4*var(--s)) calc(4*var(--s));
}
html .app-shell.pattern-p44,
.pattern-p44 {
--s: 60px; /* control the size*/
--c1: #7bb0a8;
--c2: #a7dbab;
--_c: var(--c1) calc(100% - var(--s)/2) 99%,#0000;
--_g: var(--s),#0000 calc(99% - var(--s)/2),var(--_c);
background:
radial-gradient(var(--s) at 100% var(--_g)),
radial-gradient(calc(var(--s)/4) at 50% calc(100%/3),var(--_c)) var(--s) 0,
radial-gradient(var(--s) at 0% var(--_g)) 0 calc(3*var(--s))
var(--c2);
background-size:
calc(2*var(--s)) calc(9*var(--s)/4),
calc(2*var(--s)) calc(3*var(--s)/4);
}
html .app-shell.pattern-p45,
.pattern-p45 {
--s: 30px; /* control the size*/
--c1: #e5fcc2;
--c2: #45ada8;
--_s:37.5% 12.5% at 62.5%;
--_g:34% 99%,#0000 101%;
--g1:radial-gradient(var(--_s) 100%,#0000 32%,var(--c1) var(--_g));
--g2:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c1) var(--_g));
--g3:radial-gradient(var(--_s) 100%,#0000 32%,var(--c2) var(--_g));
--g4:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c2) var(--_g));
background:
var(--g1),
var(--g2) 0 calc(3*var(--s)),
var(--g3) var(--s) calc(3*var(--s)),
var(--g4) var(--s) calc(6*var(--s)),
var(--g1) calc(2*var(--s)) calc(6*var(--s)),
var(--g2) calc(2*var(--s)) calc(9*var(--s)),
var(--g3) calc(3*var(--s)) calc(9*var(--s)),
var(--g4) calc(3*var(--s)) 0,
repeating-linear-gradient(var(--c1) 0 25%,var(--c2) 0 50%);
background-size: calc(4*var(--s)) calc(12*var(--s));
}
html .app-shell.pattern-p46,
.pattern-p46 {
--s: 65px; /* control the size*/
--c1: #ecd078;
--c2: #0b486b;
--g: calc(var(--s)/5); /* control the gap */
--_l: #0000 calc(33% - .866*var(--g)),var(--c1) calc(33.2% - .866*var(--g)) 33%,#0000 34%;
background:
repeating-linear-gradient(var(--c1) 0 var(--g), #0000 0 50%)
0 calc(.866*var(--s) - var(--g)/2),
conic-gradient(from -150deg at var(--g) 50%,var(--c1) 120deg,#0000 0),
linear-gradient(-120deg,var(--_l)),linear-gradient( -60deg,var(--_l))
var(--c2);
background-size: var(--s) calc(3.466*var(--s))
}
html .app-shell.pattern-p47,
.pattern-p47 {
--s: 100px; /* control the size*/
--c1: #f2e9e1;
--c2: #99b2b7;
background:
repeating-conic-gradient(var(--c1) 0 45deg,var(--c2) 0 90deg)
0/var(--s) var(--s);
}
html .app-shell.pattern-p48,
.pattern-p48 {
--s: 50px; /* control the size*/
--c1: #d9ceb2;
--c2: #948c75;
--_g: calc(2*var(--s)) calc(2*var(--s))
radial-gradient(25% 25%,var(--c1) 99%,#0000 101%);
background:
0 var(--s)/var(--_g),var(--s) 0/var(--_g),
radial-gradient(50% 50%,var(--c2) 97%,#0000)
calc(var(--s)/2) calc(var(--s)/2)/var(--s) var(--s),
linear-gradient(90deg,var(--c1) 50%,var(--c2) 0)
0 0/calc(2*var(--s));
}
html .app-shell.pattern-p49,
.pattern-p49 {
--s: 100px; /* control the size*/
--c1: #5a3d31;
--c2: #e5edb8;
background:
radial-gradient(#0000 50%,var(--c1) 52% 55%,var(--c2) 57%)
0 0/var(--s) var(--s),
repeating-linear-gradient(45deg,var(--c1) 0 25%,var(--c2) 0 50%)
0 0/calc(2*var(--s)) calc(2*var(--s))
}
html .app-shell.pattern-p50,
.pattern-p50 {
--s: 50px; /* control the size*/
--c1: #e08e79;
--c2: #774f38;
--c: var(--c1) 0 25%,var(--c2) 0 50%,#0000 0;
background:
conic-gradient(from 180deg,var(--c))
0/var(--s) var(--s),
repeating-conic-gradient(from 90deg,var(--c))
0/calc(3*var(--s)) calc(3*var(--s));
}
html .app-shell.pattern-p51,
.pattern-p51 {
--s: 100px; /* control the size*/
--c1: #cc2a41;
--c2: #351330;
--_g: #0000 24%,
var(--c2) 26% 34%,var(--c1) 36% 44%,
var(--c2) 46% 54%,var(--c1) 56% 64%,
var(--c2) 66% 74%,#0000 76%;
background:
radial-gradient(100% 100% at 100% 0,var(--_g)),
radial-gradient(100% 100% at 0 100%,var(--_g)),
radial-gradient(var(--c2) 14%,var(--c1) 16%)
calc(var(--s)/2) calc(var(--s)/2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p52,
.pattern-p52 {
--s: 29px; /* control the size*/
--c1: #615375;
--c2: #8eb2c5;
--g:#0000 calc(100% - var(--s)/3 - 1px),var(--c1)
calc(100% - var(--s)/3) calc(100% - 1px),#0000;
--r:calc(2.414*var(--s));
--p:calc(1.414*var(--s));
background:
radial-gradient(var(--r) at 0 0 ,var(--g)) var(--r) var(--r),
radial-gradient(var(--s) at 0 0 ,var(--g)) var(--p) var(--p),
radial-gradient(var(--r) at 0 100%,var(--g)) 0 var(--p),
radial-gradient(var(--s) at 0 100%,var(--g)) calc(-1*var(--s)) var(--r),
radial-gradient(var(--r) at 100% 0 ,var(--g)) var(--p) 0,
radial-gradient(var(--s) at 100% 0 ,var(--g)) var(--r) calc(-1*var(--s)),
radial-gradient(var(--r) at 100% 100%,var(--g)) calc(-1*var(--s)) calc(-1*var(--s)),
radial-gradient(var(--s) at 100% 100%,var(--g)) var(--c2);
background-size: calc(2*var(--r)) calc(2*var(--r));
}
html .app-shell.pattern-p53,
.pattern-p53 {
--s: 160px; /* control the size*/
--c1: #a14016;
--c2: #cfc89a;
--_g: var(--s) var(--s)
radial-gradient(var(--c1) 17%,var(--c2) 18% 35%,#0000 36.5%);
background:
calc(var(--s)/-4) calc(var(--s)/-4)/var(--_g),
calc(var(--s)/ 4) calc(var(--s)/ 4)/var(--_g),
radial-gradient(var(--c2) 34%,var(--c1) 36% 68%,#0000 70%)
0 0/calc(var(--s)/2) calc(var(--s)/2),
repeating-linear-gradient(45deg,var(--c1) -12.5% 12.5%,var(--c2) 0 37.5%)
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p54,
.pattern-p54 {
--s: 100px; /* control the size*/
--c1: #ffd500;
--c2: #0341ae;
background:
conic-gradient(at 50% 25%,var(--c2) 25%,#0000 0)
calc(var(--s)/-4) calc(var(--s)/-2),
conic-gradient(at 25% 25%,var(--c1) 25%,#0000 0 75%,var(--c2) 0)
calc(var(--s)/4) calc(var(--s)/-4),
conic-gradient(from -90deg at 75% 25%,var(--c2) 75%,var(--c1) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p55,
.pattern-p55 {
--s: 50px; /* control the size*/
--c1: #292522;
--c2: #efd9b4;
--c: #0000 75%,var(--c1) 0;
--g1: conic-gradient(at 75% 25%,var(--c));
--g2: conic-gradient(at 25% 75%,var(--c));
background:
var(--g1),var(--g1) var(--s) var(--s),
var(--g2),var(--g2) var(--s) var(--s) var(--c2);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p56,
.pattern-p56 {
--s: 6px; /* control the size*/
--c1: #3b2d38;
--c2: #f02475;
--g:#0000 66%,var(--c1) 68% 98%,#0000;
background:
radial-gradient(30% 30% at 0% 30%,var(--g))
var(--s) calc(9*var(--s)),
radial-gradient(30% 30% at 100% 30%,var(--g))
var(--s) calc(-1*var(--s)),
radial-gradient(30% 30% at 30% 100%,var(--g))
calc(10*var(--s)) 0,
radial-gradient(30% 30% at 30% 0% ,var(--g))
var(--c2);
background-size: calc(20*var(--s)) calc(20*var(--s));
}
html .app-shell.pattern-p57,
.pattern-p57 {
--s: 194px; /* control the size*/
--c1: #f6edb3;
--c2: #acc4a3;
--c3: #55897c;
--_l:#0000 calc(25%/3),var(--c1) 0 25%,#0000 0;
--_g:conic-gradient(from 120deg at 50% 87.5%,var(--c1) 120deg,#0000 0);
background:
var(--_g),var(--_g) 0 calc(var(--s)/2),
conic-gradient(from 180deg at 75%,var(--c2) 60deg,#0000 0),
conic-gradient(from 60deg at 75% 75%,var(--c1) 0 60deg,#0000 0),
linear-gradient(150deg,var(--_l)) 0 calc(var(--s)/2),
conic-gradient(at 25% 25%,#0000 50%,var(--c2) 0 240deg,var(--c1) 0 300deg,var(--c2) 0),
linear-gradient(-150deg,var(--_l)) var(--c3);
background-size: calc(0.866*var(--s)) var(--s);
}
+61
View File
@@ -0,0 +1,61 @@
// Theme helpers — derive subtle pattern shades from a user's chosen color
// swatch. Patterns in theme-patterns.css read --c1..--c4; we override them
// with hsl variations of the user's hue so the background stays subtle.
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return { h: 243, s: 75, l: 66 }; // indigo fallback
const n = parseInt(m[1], 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
// Four subtle shades built on the swatch's hue — pastel tints distinct
// enough that the pattern motif reads against the near-white app shell,
// but soft enough to stay a background wash rather than a loud print.
// `alpha` (0..1) fades the whole motif toward the shell background — the
// app-shell keeps `background: whitesmoke` underneath, so lowering it
// blends the pattern out.
export function themeShades(
hex: string,
alpha = 1
): { c1: string; c2: string; c3: string; c4: string } {
const { h } = hexToHsl(hex);
return {
c1: `hsl(${h} 45% 93% / ${alpha})`,
c2: `hsl(${h} 50% 85% / ${alpha})`,
c3: `hsl(${h} 55% 74% / ${alpha})`,
c4: `hsl(${(h + 40) % 360} 50% 87% / ${alpha})`
};
}
// Pattern ids: 'p1'..'p60', '' = none. Currently showing the first 19;
// bump this to include more (the CSS file still ships all 60).
export const PATTERN_COUNT = 19;
export function patternId(n: number): string {
return `p${n}`;
}
// Pattern size slider — the value is the pattern's `--s` in vw units, so the
// motif scales with the viewport. E.g. 7 at a 1400px screen ≈ 98px tiles.
export const THEME_SIZE_MIN = 2;
export const THEME_SIZE_MAX = 16;
export const THEME_SIZE_DEFAULT = 7;
// Pattern opacity slider — 1 = current full visibility, down to a faint hint.
export const THEME_OPACITY_MIN = 0.05;
export const THEME_OPACITY_MAX = 1;
export const THEME_OPACITY_STEP = 0.05;
export const THEME_OPACITY_DEFAULT = 1;
+12
View File
@@ -66,6 +66,14 @@ export interface Member {
updated: string; updated: string;
} }
// Family member entry for chat @-mentions (children + parents).
export interface MentionMember {
id: string;
name: string;
color: string;
role: 'parent' | 'child';
}
export interface ChoreTemplate { export interface ChoreTemplate {
id: string; id: string;
famId: string; famId: string;
@@ -138,7 +146,10 @@ 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>;
isPocketMoney?: boolean; isPocketMoney?: boolean;
completeBy?: 'unlimited' | 'week' | 'custom'; completeBy?: 'unlimited' | 'week' | 'custom';
startDate?: string; startDate?: string;
@@ -215,5 +226,6 @@ export interface Session {
famId: string; famId: string;
userId: string; userId: string;
memberName?: string; memberName?: string;
username?: string;
role?: string; role?: string;
} }
+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>
+192 -93
View File
@@ -1,14 +1,25 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import 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';
let shortcut = $state<(Shortcut & { target: string }) | null>(null); let shortcut = $state<(Shortcut & { target: string }) | null>(null);
onMount(() => { $effect(() => {
const s = readShortcut(); const s = readShortcut();
if (s) shortcut = { ...s, target: shortcutTarget(s) }; if (s) shortcut = { ...s, target: shortcutTarget(s) };
}); });
@@ -41,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>
@@ -52,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>
@@ -74,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>
@@ -87,116 +98,108 @@
<div class="explainer"> <div class="explainer">
<div class="steps"> <div class="steps">
<article class="step"> <article class="step">
<div class="step-head">
<span class="step-media">
<span class="step-icon">{@html homeIcon}</span>
<span class="step-num">1</span> <span class="step-num">1</span>
</span>
<h3>Create your family</h3> <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">
<div class="step-head">
<span class="step-media">
<span class="step-icon">{@html choresIcon}</span>
<span class="step-num">2</span> <span class="step-num">2</span>
</span>
<h3>Kids do the work</h3> <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">
<div class="step-head">
<span class="step-media">
<span class="step-icon">{@html coinsIcon}</span>
<span class="step-num">3</span> <span class="step-num">3</span>
<h3>Allowance pays out</h3> </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">
<div class="feature-head">
<span class="feature-icon">{@html dashboardIcon}</span>
<h3>Chore board</h3> <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">
<p> <span class="feature-icon">{@html shieldIcon}</span>
Realtime sync across the whole family — see points and progress update the moment a <h3>Trust!</h3>
chore is done. </div>
</p> <p>The whole experience is about exercising trust together.</p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#fef3c7; --ic-fg:#d97706">
<h3>Automatic allowance</h3> <div class="feature-head">
<p> <span class="feature-icon">{@html gemIcon}</span>
Points convert to money using your family's own rules. No mental maths, no arguments.
</p>
</article>
<article class="feature">
<h3>Rewards &amp; bonuses</h3> <h3>Rewards &amp; bonuses</h3>
</div>
<p> <p>
Kids can spend points on rewards, and you can run a monthly bonus for the top earner. As a parent you can dish out behaviour rewards or pocket money boosters - in a place
which is yours to create together.
</p> </p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#e0f2fe; --ic-fg:#0284c7">
<h3>Kid-friendly</h3> <div class="feature-head">
<p> <span class="feature-icon">{@html laptopIcon}</span>
A simple, colourful interface kids love — big buttons, clear feedback, their own space. <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">
<div class="feature-head">
<span class="feature-icon">{@html lockIcon}</span>
<h3>Private by design</h3> <h3>Private by design</h3>
</div>
<p> <p>
Everything is scoped to your family. Kids join with an invite code and stay in your We don't read your data, your messages, or sell anything - your family and rules is your
family. property.
</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>
@@ -556,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;
@@ -597,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;
@@ -644,4 +738,9 @@
font-size: 2rem; font-size: 2rem;
} }
} }
@media (max-width: 639px) {
h1 {
font-size: 1.5em;
}
}
</style> </style>
+37 -6
View File
@@ -6,13 +6,44 @@
<div class="error-page"> <div class="error-page">
<h1>Something went wrong</h1> <h1>Something went wrong</h1>
<p>{error?.message || 'An unexpected error occurred.'}</p> <p>{error?.message || 'An unexpected error occurred.'}</p>
<p class="hint">Try <a href="/logout">logging out</a> and signing in again.</p> <div class="hint">
Try
<form method="POST" action="/logout" class="inline-form">
<button type="submit" class="link-button">logging out</button>
</form>
and signing in again.
</div>
</div> </div>
<style> <style>
.error-page { max-width: 480px; margin: 4rem auto; text-align: center; padding: 2rem; } .error-page {
h1 { font-size: 1.5rem; color: #111827; margin-bottom: 0.5rem; } max-width: 480px;
p { color: #6b7280; } margin: 4rem auto;
.hint { margin-top: 1.5rem; font-size: 0.9rem; } text-align: center;
a { color: #6366f1; } padding: 2rem;
}
h1 {
font-size: 1.5rem;
color: #111827;
margin-bottom: 0.5rem;
}
p {
color: #6b7280;
}
.hint {
margin-top: 1.5rem;
font-size: 0.9rem;
}
.inline-form {
display: inline-block;
}
.link-button {
background: none;
border: none;
padding: 0;
font: inherit;
color: #6366f1;
text-decoration: underline;
cursor: pointer;
}
</style> </style>
+130 -9
View File
@@ -1,6 +1,10 @@
import { createPbClient } from '$lib/server/pocketbase'; import { redirect } from '@sveltejs/kit';
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 { 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 {
@@ -25,13 +29,16 @@ function actorFrom(session: {
}; };
} }
async function resolveChatIdentity(session: { async function resolveChatIdentity(
session: {
famId: string; famId: string;
id: string; id: string;
role: string; role: string;
name?: string; name?: string;
color?: string; color?: string;
}, pbToken: string) { },
pbToken: string
) {
try { try {
const s = createServices(createPbClient(pbToken)); const s = createServices(createPbClient(pbToken));
const actor = actorFrom(session); const actor = actorFrom(session);
@@ -42,15 +49,84 @@ async function resolveChatIdentity(session: {
} }
export async function load(event) { export async function load(event) {
// Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent).
if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) {
seedDemoCompletions().catch(() => {});
}
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: { famId: string; actor: ChatActor } | null = null; let chat: {
famId: string;
actor: ChatActor;
members?: { id: string; name: string; color: string; role: 'parent' | 'child' }[];
} | null = null;
let fam: any = null; let fam: any = null;
let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' }; let famAccess = {
disabled: false,
mode: 'none' as 'none' | 'code' | 'sub' | 'canceled',
reason: ''
};
let lockMins = 0;
if (session && pbToken) { if (session && pbToken) {
famId = session.famId; famId = session.famId;
@@ -58,23 +134,58 @@ 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
// 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
// slug so the id is replaced everywhere it'd otherwise persist.
const canonicalSlug = fam?.slug;
if (canonicalSlug && paramFam && paramFam !== canonicalSlug) {
const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/';
const qs = event.url.search;
throw redirect(303, `/${encodeURIComponent(canonicalSlug)}${rest}${qs}`);
} }
}
const demoMode = (await getPlatformFlags()).demo;
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,
userId: session.id, userId: session.id,
memberName: session.name, memberName: session.name,
memberColor: session.color || '', memberColor: session.color || '',
memberPattern: session.pattern || '',
memberThemeSize: session.themeSize || '',
memberThemeOpacity: session.themeOpacity || '',
username: session.username || '',
role: session.role role: session.role
} }
: null, : null,
@@ -84,6 +195,16 @@ export async function load(event) {
chat, chat,
pbToken, pbToken,
fam, fam,
famAccess famAccess,
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'
}; };
} }
+259 -21
View File
@@ -6,15 +6,75 @@
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 '$lib/theme-patterns.css';
import { themeDraft } from '$lib/stores/theme.svelte';
import type { Session } from '$lib/types'; import type { Session } from '$lib/types';
let { children, data } = $props(); let { children, data } = $props();
// Background pattern ('' = none) tinted with the user's colour. A live
// draft (clicked in the picker, not yet saved) previews immediately;
// otherwise the saved value from the session applies.
let bgPattern = $derived(themeDraft.pattern ?? (data.session?.memberPattern || ''));
let bgColor = $derived(themeDraft.color ?? (data.session?.memberColor || '#6366f1'));
let bgOpacity = $derived(
themeDraft.opacity ??
(Number(data.session?.memberThemeOpacity) > 0 ? data.session?.memberThemeOpacity : 1)
);
let bgShades = $derived(themeShades(bgColor, Number(bgOpacity) || 1));
// Pattern size in vw units ('' = untouched → pattern class default).
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
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);
// 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
); );
@@ -23,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
@@ -114,17 +182,24 @@
// session exists. $effect (not onMount) so it also fires when the session // session exists. $effect (not onMount) so it also fires when the session
// appears after the layout was already mounted — e.g. a member redeeming // appears after the layout was already mounted — e.g. a member redeeming
// 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
// 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.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.role === 'parent' ? '' : page.params.username || '' userName: page.data.session.username || page.params.username || ''
}); });
}); });
$effect(() => { $effect(() => {
if (!session || !famStore.initialized) return; if (!isParent || !session || !famStore.initialized) return;
const rewards = famStore.rewards; const rewards = famStore.rewards;
if (!mounted) { if (!mounted) {
mounted = true; mounted = true;
@@ -168,18 +243,35 @@
actorType: chat.actor.type, actorType: chat.actor.type,
actorName: chat.actor.name, actorName: chat.actor.name,
actorColor: chat.actor.color || '#6366f1', actorColor: chat.actor.color || '#6366f1',
pbToken: page.data.pbToken || '' pbToken: page.data.pbToken || '',
members: chat.members || []
}); });
}); });
</script> </script>
<div class="layout-stage" class:chat-open={chatStore.open}> <!-- layout.svelte picker-mode branch -->
<div class="app-shell"> {#if pickerMode}
<Sidebar {famName} session={data.session} {isParent} {role} /> <main class="picker-stage">
<SharedPicker
standalone
famSlug={data.famSlug}
famName={data.pickerFamName}
profiles={data.pickerChildren || []}
/>
</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}`}
>
>
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
<TopNav <TopNav
{role} role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)} seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''} announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
connected={!famStore.connectionDown}
> >
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat"> <button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon} {@html chatIcon}
@@ -187,11 +279,44 @@
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span> <span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
{/if} {/if}
</button> </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> </TopNav>
<main class="app-main"> <main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap"> <div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div> <div class="page-content" class:locked>{@render children()}</div>
{#if 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} {#if locked}
<div class="disabled-overlay"> <div class="disabled-overlay">
<div class="disabled-card"> <div class="disabled-card">
@@ -215,7 +340,12 @@
</div> </div>
</main> </main>
{#if claimToast} {#if claimToast}
<div class="claim-toast">{claimToast}</div> <a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
>
{claimToast} — view dashboard →
</a>
{/if} {/if}
<Footer sidebar /> <Footer sidebar />
</div> </div>
@@ -224,22 +354,65 @@
></button> ></button>
{/if} {/if}
<Chat {role} /> <Chat {role} />
</div> {#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}
</div>
{/if}
<style> <style>
.claim-toast { .claim-toast {
position: fixed; position: fixed;
top: 3.5rem; top: 52px;
right: 1.5rem; left: 220px;
background: #059669; right: 0;
color: white; background: #f59e0b;
padding: 0.6rem 1rem; color: #fff;
border-radius: 8px; padding: 0.5rem 1.5rem;
font-size: 0.85rem; font-size: 0.85rem;
z-index: 999; font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 95;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
text-decoration: none;
animation: fadein 0.2s; animation: fadein 0.2s;
} }
.claim-toast:hover {
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) {
.claim-toast {
left: 56px;
}
}
@media (max-width: 639px) {
.claim-toast {
left: 0;
padding: 0.5rem 1rem;
}
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@@ -280,6 +453,16 @@
margin-left: 56px; margin-left: 56px;
} }
} }
@media (max-width: 639px) {
.app-main {
padding-left: 1rem;
padding-right: 1rem;
}
/* Join pages center their own content — no shell padding needed */
.app-main.join-page {
padding: 0;
}
}
.page-wrap { .page-wrap {
position: relative; position: relative;
min-height: 70vh; min-height: 70vh;
@@ -334,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;
@@ -347,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;
+5 -3
View File
@@ -9,14 +9,16 @@ export async function load(event) {
const s = createServices(pbUser(event), session); const s = createServices(pbUser(event), session);
try { try {
const [members, assigned, summary, fam, stats] = await Promise.all([ const [members, assigned, summary, fam, stats, rewards, bonusConfigs] = await Promise.all([
s.crud.list('members', famId), s.crud.list('members', famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', famId),
s.fam.weeklySummary(famId), s.fam.weeklySummary(famId),
s.fam.get(famId), s.fam.get(famId),
s.stats.family(famId) s.stats.family(famId),
s.crud.list('rewards', famId),
s.crud.list('bonus-configs', famId)
]); ]);
return { members, assigned, summary, fam, stats }; return { members, assigned, summary, fam, stats, rewards, bonusConfigs };
} catch { } catch {
return {}; return {};
} }
+268 -51
View File
@@ -4,11 +4,29 @@
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
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 { 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);
@@ -48,12 +66,81 @@
let tileChores = $derived( let tileChores = $derived(
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0 summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
); );
// ─── All-time header stats (server-computed) ─── // ─── All-time header stats (server-computed) ───
let allTimePts = $derived(Number(stats.allTimePts) || 0); let allTimePts = $derived(Number(stats.allTimePts) || 0);
let allTimeCash = $derived(Number(stats.allTimeCash) || 0); let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
let allTimeChores = $derived(Number(stats.allTimeChores) || 0); let allTimeChores = $derived(Number(stats.allTimeChores) || 0);
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
// Derived from famStore so realtime updates (a goal met / revoked) flow in.
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
// All-time goals achieved — every materialized bonus reward (prize / points /
// cash), including pocket money configs.
let tileGoals = $derived(rewards.filter((r: any) => r.bonusConfigId).length);
let bonusConfigs = $derived(
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
);
let goalsByMember = $derived.by(() => {
const ws = (summary?.weekStart as string) || todayInTz(famTz);
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;
if ((r.date || '').slice(0, 10) < ws) 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 pocketMoneyByMember = $derived.by(() => {
const ws = (summary?.weekStart as string) || todayInTz(famTz);
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;
if ((r.date || '').slice(0, 10) < ws) continue;
const entry = map.get(r.memberId) || 0;
map.set(r.memberId, entry + 1);
}
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(() => {
const series = Array.isArray(stats.series) ? stats.series : []; const series = Array.isArray(stats.series) ? stats.series : [];
@@ -117,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 {
@@ -156,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' })
@@ -218,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
); );
} }
@@ -272,12 +363,23 @@
</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">
<button
class="shared-hero-toggle"
class:on={sharedDevice.on}
onclick={() => sharedDevice.toggle()}
aria-pressed={sharedDevice.on}
title={sharedDevice.on ? 'Shared computer mode is ON' : 'Mark this as a shared computer'}
>
{@html monitorIcon}
<span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span>
</button>
<div class="vh-stats text-right">
<span class="vh-stat"> <span class="vh-stat">
<span class="vh-num pts">{allTimePts.toLocaleString()}</span> <span class="vh-num pts">{allTimePts.toLocaleString()}</span>
<span class="vh-lbl">points all time</span> <span class="vh-lbl">points all time</span>
@@ -287,6 +389,7 @@
<span class="vh-lbl">cash earned all time</span> <span class="vh-lbl">cash earned all time</span>
</span> </span>
</div> </div>
</div>
</ViewHeader> </ViewHeader>
<!-- STAT TILES --> <!-- STAT TILES -->
@@ -303,6 +406,10 @@
<span class="tile-val">£{tileMoney.toFixed(2)}</span> <span class="tile-val">£{tileMoney.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span> <span class="tile-lbl">cash this week</span>
</div> </div>
<div class="tile tile-goals">
<span class="tile-val">{tileGoals}</span>
<span class="tile-lbl">goals achieved</span>
</div>
</div> </div>
<CardGrid> <CardGrid>
@@ -337,10 +444,11 @@
{@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 goals = goalsByMember.get(s.memberId)}
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0} {@const goalsAll = goalsAllTimeByMember.get(s.memberId)}
<div class="column col-member"> {@const at = allTimeByMember.get(s.memberId)}
<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>
<span class="member-name">{s.memberName}</span> <span class="member-name">{s.memberName}</span>
@@ -361,22 +469,20 @@
</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>
{#if (goals?.count ?? 0) > 0}<span class="mstat-goals" title={goals?.names.join(' · ')}>🎉{goals?.count}</span>{/if}
</div> </div>
<div class="stat-row"> <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="stat-lbl">Cash</span> <span class="mstat-tag">All time</span>
<span class="stat-val cash">£{Number(s.moneyEarned || 0).toFixed(2)}</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>
</div> {#if (goalsAll?.count ?? 0) > 0}<span class="mstat-goals" title={goalsAll?.names.join(' · ')}>🎉{goalsAll?.count}</span>{/if}
<div class="stat-row">
<span class="stat-lbl">Done</span>
<span class="stat-val">{wkDone}/{wkTotal}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -439,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 {
@@ -497,7 +635,8 @@
/* ── Tiles (matching child dashboard) ── */ /* ── Tiles (matching child dashboard) ── */
.tiles { .tiles {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); /* members 1 unit wide; goals / points / cash 2 units each (1+2+2+2 on one row) */
grid-template-columns: 1fr 2fr 2fr 2fr;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -505,6 +644,9 @@
.tiles { .tiles {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.vh-num {
font-size: 1.5em;
}
} }
.tile { .tile {
border-radius: 14px; border-radius: 14px;
@@ -527,6 +669,9 @@
.tile-chores { .tile-chores {
background: linear-gradient(135deg, #0284c7, #38bdf8); background: linear-gradient(135deg, #0284c7, #38bdf8);
} }
.tile-goals {
background: linear-gradient(135deg, #f59e0b, #f97316);
}
.tile-val { .tile-val {
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
@@ -549,12 +694,13 @@
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 {
border-top-color: #6366f1; border-top-color: #6366f1;
background: #eef2ff; /* Translucent wash of the member's own colour (set via --mc inline) */
background: color-mix(in srgb, var(--mc, #6366f1) 12%, transparent);
} }
.member-head { .member-head {
@@ -563,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;
@@ -595,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;
@@ -634,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;
@@ -656,6 +858,12 @@
.stat-val.cash { .stat-val.cash {
color: #059669; color: #059669;
} }
.stat-val.goals {
color: #059669;
}
.stat-val.pm {
color: #d97706;
}
/* ── Chart ── */ /* ── Chart ── */
.chart-wrap { .chart-wrap {
@@ -672,4 +880,13 @@
text-align: center; text-align: center;
padding: 1.5rem; padding: 1.5rem;
} }
/* Mobile: member columns stack full width (same convention as the chores
kanban) instead of the 450px horizontal scroll. Declared last so it wins
over the base .kanban rule. */
@media (max-width: 639px) {
.kanban {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style> </style>
@@ -1,20 +1,28 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { pbUser, createPbClient } from '$lib/server/pocketbase'; import { pbUser, createPbClient } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services'; import { createServices } from '$lib/server/services';
import { getPlatformFlags } from '$lib/server/platform';
import { pbAdmin } from '$lib/server/pocketbase';
export async function load(event) { export async function load(event) {
const session = event.locals.user; const session = event.locals.user;
const demoMode = (await getPlatformFlags()).demo;
const isDemoParent = session?.username === 'demo';
const famSlug = event.params.fam;
const username = event.params.username;
// Demo parent visiting a child's route → load child kanban view.
const demoParentVisitingChild = demoMode && isDemoParent && session?.username !== username;
// Parent (role=parent, session auth) → admin overview. // Parent (role=parent, session auth) → admin overview.
// Branch on role, NOT presence: a child who OTP-logged-in also has a // Branch on role, NOT presence: a child who OTP-logged-in also has a
// `users` session (locals.user) and must not hit the parent path. // `users` session (locals.user) and must not hit the parent path.
if (session && session.role === 'parent') { if (session && session.role === 'parent' && !demoParentVisitingChild) {
const famId = session.famId; if (!(demoMode && isDemoParent)) {
const famSlug = event.params.fam;
const username = event.params.username;
if (session.name && session.username && session.username !== username) { if (session.name && session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
}
const s = createServices(pbUser(event), session); const s = createServices(pbUser(event), session);
const [ const [
members, members,
@@ -27,15 +35,15 @@ export async function load(event) {
completions, completions,
settings settings
] = await Promise.all([ ] = await Promise.all([
s.crud.list('members', famId), s.crud.list('members', session.famId),
s.crud.list('chore-templates', famId), s.crud.list('chore-templates', session.famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', session.famId),
s.fam.weeklySummary(famId), s.fam.weeklySummary(session.famId),
s.fam.get(famId), s.fam.get(session.famId),
s.crud.list('rewards', famId), s.crud.list('rewards', session.famId),
s.crud.list('bonus-configs', famId), s.crud.list('bonus-configs', session.famId),
s.crud.list('completions', famId), s.crud.list('completions', session.famId),
s.settings.get(famId) s.settings.get(session.famId)
]); ]);
return { return {
role: 'parent', role: 'parent',
@@ -52,18 +60,24 @@ export async function load(event) {
}; };
} }
// Child (users auth, role=child) → kanban // Child (users auth, role=child) → kanban.
const famSlug = event.params.fam; // Also demo parent visiting a child's route.
const username = event.params.username;
// A child should always land on their own kanban route. // A child should always land on their own kanban route.
if (!(demoMode && isDemoParent)) {
if (session?.username && session.username !== username) { if (session?.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
}
const pbToken = event.cookies.get('pb_token') || ''; const pbToken = event.locals.pbToken || '';
const famId = session?.famId || ''; const famId = session?.famId || '';
const childId = session?.id || ''; // 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.
const childId = demoParentVisitingChild
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]
?.id || ''
: session?.id || '';
const empty = { const empty = {
role: 'child' as const, role: 'child' as const,
@@ -73,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[],
@@ -97,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 || [],
File diff suppressed because it is too large Load Diff
@@ -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;
@@ -197,7 +197,7 @@
const isShared = assignTarget === 'shared'; const isShared = assignTarget === 'shared';
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
memberId: isShared ? '' : assignModal.memberId, memberId: isShared ? '' : assignModal.memberId,
templateId: assignModal.t.id, templateId: assignModal.t?.id || '',
customName: assignName, customName: assignName,
description: assignDescription, description: assignDescription,
color: assignColor, color: assignColor,
@@ -242,6 +242,21 @@
showTodoModal = true; showTodoModal = true;
} }
function openCreateChore(memberId = '') {
const target = memberId || members[0]?.id || '';
assignModal = { t: null as any, memberId: target };
assignStep = 1;
assignTarget = target ? 'member' : 'shared';
assignName = '';
assignDescription = '';
assignColor = '';
assignIcon = '';
assignFreq = 'daily';
assignType = 'points';
assignValue = 10;
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
}
// Sort todos by completeBy (soonest first), then by startDate // Sort todos by completeBy (soonest first), then by startDate
function todoSort(a: AssignedChore, b: AssignedChore): number { function todoSort(a: AssignedChore, b: AssignedChore): number {
const da = a.completeBy || '9999-99-99'; const da = a.completeBy || '9999-99-99';
@@ -376,11 +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>
</div> </div>
<div class="template-list"> <div class="template-list">
{#each templates as t} {#each templates as t}
@@ -592,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>
@@ -619,8 +640,7 @@
<span class="accordion-label">Competitive Chores</span> <span class="accordion-label">Competitive Chores</span>
</span> </span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html >{@html (accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
(accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
</button> </button>
<span class="accordion-count" style="background:#6366f1" <span class="accordion-count" style="background:#6366f1"
@@ -676,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>
@@ -720,12 +743,15 @@
<div <div
class="overlay" class="overlay"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; } if (e.target === e.currentTarget) {
assignModal = null;
assignStep = 1;
}
}} }}
role="presentation" role="presentation"
> >
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Assign: {assignModal.t.name}</h3> <h3>{assignModal.t ? `Assign: ${assignModal.t.name}` : 'Create a chore'}</h3>
<!-- Step tabs --> <!-- Step tabs -->
<div class="step-tabs" role="tablist"> <div class="step-tabs" role="tablist">
@@ -743,14 +769,22 @@
{#if assignStep === 1} {#if assignStep === 1}
<!-- Step 1: Who --> <!-- Step 1: Who -->
<label>Name <input bind:value={assignName} placeholder="Task name" /></label> <label>Name <input bind:value={assignName} placeholder="Task name" /></label>
<label>Description <input bind:value={assignDescription} placeholder="Optional description" /></label> <label
>Description <input
bind:value={assignDescription}
placeholder="Optional description"
/></label
>
<label> <label>
Assign to Assign to
<select bind:value={assignTarget} onchange={() => { <select
bind:value={assignTarget}
onchange={() => {
if (assignTarget === 'member' && !assignModal?.memberId && members.length) { if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
assignModal = { ...assignModal!, memberId: members[0].id }; assignModal = { ...assignModal!, memberId: members[0].id };
} }
}}> }}
>
<option value="member">A specific member</option> <option value="member">A specific member</option>
<option value="shared">Shared (all members)</option> <option value="shared">Shared (all members)</option>
</select> </select>
@@ -824,7 +858,13 @@
{#if assignStep > 1} {#if assignStep > 1}
<button type="button" onclick={() => assignStep--}>Back</button> <button type="button" onclick={() => assignStep--}>Back</button>
{:else} {:else}
<button type="button" onclick={() => { assignModal = null; assignStep = 1; }}>Cancel</button> <button
type="button"
onclick={() => {
assignModal = null;
assignStep = 1;
}}>Cancel</button
>
{/if} {/if}
{#if assignStep < 3} {#if assignStep < 3}
<button type="button" class="primary" onclick={() => assignStep++}>Next</button> <button type="button" class="primary" onclick={() => assignStep++}>Next</button>
@@ -1162,8 +1202,13 @@
.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 {
display: flex; display: flex;
@@ -1236,6 +1281,7 @@
.add-inline:hover { .add-inline:hover {
border-color: #6366f1; border-color: #6366f1;
background: #eef2ff; background: #eef2ff;
color: #4338ca;
} }
.card { .card {
@@ -1849,4 +1895,23 @@
opacity: 0.5; opacity: 0.5;
min-height: 200px; min-height: 200px;
} }
/* Mobile: declared last so it wins over the base .kanban/.column rules
(same specificity, source order decides). */
@media (max-width: 639px) {
.template-list {
display: none;
}
.kanban {
flex-direction: column;
overflow-x: visible;
}
.column {
flex: none;
width: 100%;
min-height: auto;
}
.column-placeholder {
display: none;
}
}
</style> </style>
@@ -5,14 +5,15 @@ export async function load(event) {
if (!event.locals.user) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId; const famId = event.locals.user.famId;
const s = servicesFor(event); const s = servicesFor(event);
const [rewards, members, assigned, templates, completions] = await Promise.all([ const [rewards, members, assigned, templates, completions, fam] = await Promise.all([
s.crud.list('rewards', famId), s.crud.list('rewards', famId),
s.crud.list('members', famId), s.crud.list('members', famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', famId),
s.crud.list('chore-templates', famId), s.crud.list('chore-templates', famId),
s.crud.list('completions', famId) s.crud.list('completions', famId),
s.fam.get(famId)
]); ]);
return { rewards, members, assigned, templates, completions }; return { rewards, members, assigned, templates, completions, fam };
} }
export const actions = { export const actions = {
@@ -2,7 +2,8 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { formatDDMMYY } from '$lib/format'; import { formatDDMMYY, formatShortDate } from '$lib/format';
import { resolveTz, weekStart as tzWeekStart, addDaysStr } from '@shared/timezone';
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types'; import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
let { data } = $props(); let { data } = $props();
@@ -24,6 +25,28 @@
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[]) famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
); );
// ── Week navigation (mirrors the member dashboard: payday-anchored weeks) ──
// Anchor comes from data.fam at SSR; famStore takes over after init so a
// payday/timezone change in settings flows through too.
let fam = $derived(
famStore.initialized ? (famStore.fam as any) : ((data as any).fam as any) || {}
);
let famPayday = $derived(fam?.payday != null ? Number(fam.payday) : 1);
let famTz = $derived(resolveTz(fam?.timezone || 'auto'));
let currentWeek = $derived.by(() => tzWeekStart(famPayday, famTz));
let weekStart = $state(tzWeekStart(famPayday, famTz));
let weekEnd = $derived(addDaysStr(weekStart, 6));
let isCurrentWeek = $derived(weekStart === currentWeek);
function inWeek(dateStr: string | undefined): boolean {
const d = (dateStr || '').slice(0, 10);
return !!d && d >= weekStart && d <= weekEnd;
}
function navigateWeek(amount: number) {
weekStart = addDaysStr(weekStart, amount * 7);
}
let activeTab = $state('rewards'); let activeTab = $state('rewards');
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m]))); let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
@@ -43,10 +66,22 @@
} }
// ── Rewards tab ── // ── Rewards tab ──
// Rewards store the label as "Bonus name – prize" (built in bonuses.ts:
// cash → "Reading Bonus 3 – £5.00", points → "Reading Bonus 3 – 50",
// items → "Reading Bonus 3 – fluffy toy"). Split it so the description
// column shows the bonus name and the amount column shows the prize;
// labels without a separator keep the full text in both columns.
function rewardLabelParts(r: any): { name: string; prize: string } {
const label = r.label || '';
const idx = label.indexOf(' – ');
if (idx === -1) return { name: label, prize: label };
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
}
function rewardAmount(r: any): string { function rewardAmount(r: any): string {
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`; if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`; if (r.rewardType === 'points') return `${r.value} pts`;
return r.label; return rewardLabelParts(r).prize;
} }
function rewardStatus(r: any): string { function rewardStatus(r: any): string {
@@ -71,8 +106,11 @@
}) })
); );
let weekRewards = $derived(sortedRewards.filter((r: any) => inWeek(r.date)));
// Footer total matches the visible week, not all-time history.
let totalOutstanding = $derived( let totalOutstanding = $derived(
rewards weekRewards
.filter((r: any) => r.status !== 'claimed') .filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash') .filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0) .reduce((sum: number, r: any) => sum + Number(r.value), 0)
@@ -93,6 +131,9 @@
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || '')) [...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
); );
let weekChoreCompletions = $derived(sortedChoreCompletions.filter((c: any) => inWeek(c.date)));
let weekTodoCompletions = $derived(sortedTodoCompletions.filter((c: any) => inWeek(c.date)));
let toast = $state(''); let toast = $state('');
async function fire() { async function fire() {
@@ -125,6 +166,13 @@
active: activeTab, active: activeTab,
onchange: (v: string) => (activeTab = v) onchange: (v: string) => (activeTab = v)
}} }}
weeknav={{
current: isCurrentWeek ? 'This week' : formatShortDate(weekStart),
onPrev: () => navigateWeek(-1),
onNext: () => navigateWeek(1),
onCurrent: () => (weekStart = currentWeek),
isCurrent: isCurrentWeek
}}
/> />
{#if toast} {#if toast}
@@ -134,10 +182,11 @@
<CardGrid stackTablet> <CardGrid stackTablet>
{#if activeTab === 'rewards'} {#if activeTab === 'rewards'}
<Card cols={3}> <Card cols={3}>
{#if rewards.length === 0} {#if weekRewards.length === 0}
<p class="empty"> <p class="empty">
No rewards yet. Points earned will automatically create bonus rewards when thresholds are {isCurrentWeek
met. ? 'No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.'
: `No rewards for the week of ${formatShortDate(weekStart)}.`}
</p> </p>
{:else} {:else}
<table> <table>
@@ -152,15 +201,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedRewards as r} {#each weekRewards as r}
{@const outstanding = isOutstanding(r)} {@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}> <tr
class:claimed={!outstanding && !isRequested(r)}
class:requested={isRequested(r)}
style="--mc:{memberColor(r.memberId)}"
>
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td> <td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
<td> <td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span> <span class="dot" style="background:{memberColor(r.memberId)}"></span>
{memberName(r.memberId)} {memberName(r.memberId)}
</td> </td>
<td>{r.label}</td> <td>{rewardLabelParts(r).name}</td>
<td class="num">{rewardAmount(r)}</td> <td class="num">{rewardAmount(r)}</td>
<td> <td>
<span <span
@@ -208,8 +261,12 @@
</Card> </Card>
{:else if activeTab === 'chores'} {:else if activeTab === 'chores'}
<Card cols={3} scrollX title="Chore completions"> <Card cols={3} scrollX title="Chore completions">
{#if sortedChoreCompletions.length === 0} {#if weekChoreCompletions.length === 0}
<p class="empty">No chore completions yet.</p> <p class="empty">
{isCurrentWeek
? 'No chore completions yet.'
: `No chore completions for the week of ${formatShortDate(weekStart)}.`}
</p>
{:else} {:else}
<table> <table>
<thead> <thead>
@@ -221,9 +278,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedChoreCompletions as c} {#each weekChoreCompletions as c}
{@const a = assignedMap.get(c.assignedChoreId)} {@const a = assignedMap.get(c.assignedChoreId)}
<tr> <tr style="--mc:{memberColor(c.memberId)}">
<td class="date">{formatDDMMYY(c.date)}</td> <td class="date">{formatDDMMYY(c.date)}</td>
<td> <td>
<span class="dot" style="background:{memberColor(c.memberId)}"></span> <span class="dot" style="background:{memberColor(c.memberId)}"></span>
@@ -242,8 +299,12 @@
</Card> </Card>
{:else} {:else}
<Card cols={3} scrollX title="Todo completions"> <Card cols={3} scrollX title="Todo completions">
{#if sortedTodoCompletions.length === 0} {#if weekTodoCompletions.length === 0}
<p class="empty">No todos completed yet.</p> <p class="empty">
{isCurrentWeek
? 'No todos completed yet.'
: `No todos completed for the week of ${formatShortDate(weekStart)}.`}
</p>
{:else} {:else}
<table> <table>
<thead> <thead>
@@ -255,9 +316,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedTodoCompletions as c} {#each weekTodoCompletions as c}
{@const a = assignedMap.get(c.assignedChoreId)} {@const a = assignedMap.get(c.assignedChoreId)}
<tr> <tr style="--mc:{memberColor(c.memberId)}">
<td class="date">{formatDDMMYY(c.date)}</td> <td class="date">{formatDDMMYY(c.date)}</td>
<td> <td>
<span class="dot" style="background:{memberColor(c.memberId)}"></span> <span class="dot" style="background:{memberColor(c.memberId)}"></span>
@@ -356,4 +417,20 @@
border-top: 2px solid #d1d5db; border-top: 2px solid #d1d5db;
padding-top: 0.6rem; padding-top: 0.6rem;
} }
@media (max-width: 639px) {
/* Hide Member (2nd) and Status (5th, rewards only) columns */
th:nth-child(2),
td:nth-child(2) {
display: none;
}
th:nth-child(5),
td:nth-child(5) {
display: none;
}
/* Member colour as the entry background instead of the member column */
tbody tr,
tbody tr.requested {
background: color-mix(in srgb, var(--mc, #6366f1) 10%, transparent);
}
}
</style> </style>
@@ -1,13 +1,45 @@
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 { hasPin } from '$lib/server/pins';
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
}
export async function load(event) { export async function load(event) {
if (await isDemoMode()) {
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;
const famSlug = event.params.fam; const famSlug = event.params.fam;
const username = event.params.username; const username = event.params.username;
if (!session) { if (!session) {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; return {
verified: false,
role: '',
token: '',
memberId: '',
famId: '',
memberName: '',
memberColor: '',
email: ''
};
} }
const famId = session.famId; const famId = session.famId;
@@ -21,20 +53,52 @@ export async function load(event) {
try { try {
const me = await s.fam.getProfile(famId); const me = await s.fam.getProfile(famId);
return { return {
verified: true, token: '', memberId: me.id, famId, verified: true,
memberName: me.name, memberColor: me.color, email: me.email || '', role: 'parent',
session: true, token: '',
memberId: me.id,
famId,
memberName: me.name,
memberColor: me.color,
pattern: me.pattern || '',
themeSize: me.themeSize || '',
themeOpacity: me.themeOpacity || '',
partyEmoji: me.partyEmoji || '',
email: me.email || '',
hasPin: false,
session: true
}; };
} catch { } catch {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; return {
verified: false,
role: '',
token: '',
memberId: '',
famId: '',
memberName: '',
memberColor: '',
email: '',
hasPin: false
};
} }
} }
// Child — profile lives on the users record, read from the session. // Child — profile lives on the users record, read from the session.
return { return {
verified: true, token: event.cookies.get('pb_token') || '', memberId: session.id, famId, verified: true,
memberName: session.name || '', memberColor: session.color || '', email: '', role: 'child',
session: true, token: event.locals.pbToken || '',
memberId: session.id,
famId,
memberName: session.name || '',
memberColor: session.color || '',
pattern: session.pattern || '',
themeSize: session.themeSize || '',
themeOpacity: session.themeOpacity || '',
partyEmoji: session.partyEmoji || '',
email: '',
hasPin: await hasPin(session.id).catch(() => false),
session: true
}; };
} }
@@ -47,6 +111,10 @@ export const actions = {
const name = fd.get('name') as string; const name = fd.get('name') as string;
const color = fd.get('color') as string; const color = fd.get('color') as string;
const email = fd.get('email') as string; const email = fd.get('email') as string;
const pattern = (fd.get('pattern') as string) || '';
const themeSize = (fd.get('themeSize') 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;
@@ -56,9 +124,22 @@ export const actions = {
const data: Record<string, string> = {}; const data: Record<string, string> = {};
if (name) data.name = name; if (name) data.name = name;
if (color) data.color = color; if (color) data.color = color;
data.pattern = pattern;
data.themeSize = themeSize;
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 { success: true, name: me.name, color: me.color, email: me.email }; return {
success: true,
name: me.name,
color: me.color,
pattern: me.pattern,
themeSize: me.themeSize,
themeOpacity: me.themeOpacity,
partyEmoji: me.partyEmoji,
email: me.email
};
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' }; return { error: e instanceof Error ? e.message : 'Update failed' };
} }
@@ -69,10 +150,23 @@ export const actions = {
const data: Record<string, string> = {}; const data: Record<string, string> = {};
if (name) data.name = name; if (name) data.name = name;
if (color) data.color = color; if (color) data.color = color;
data.pattern = pattern;
data.themeSize = themeSize;
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 { success: true, name: me.name, color: me.color, email: '' }; return {
success: true,
name: me.name,
color: me.color,
pattern: me.pattern,
themeSize: me.themeSize,
themeOpacity: me.themeOpacity,
partyEmoji: me.partyEmoji,
email: ''
};
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' }; return { error: e instanceof Error ? e.message : 'Update failed' };
} }
}, }
}; };
@@ -1,14 +1,83 @@
<script lang="ts"> <script lang="ts">
import { enhance, applyAction } from '$app/forms'; import { enhance, applyAction } from '$app/forms';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { invalidateAll } from '$app/navigation';
import { ViewHeader, CardGrid, Card, Button, PatternPicker } from '$lib/components';
import { themeDraft } from '$lib/stores/theme.svelte';
import { THEME_SIZE_DEFAULT, THEME_OPACITY_DEFAULT } from '$lib/theme';
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 || '');
let memberPattern = $state(data.pattern || '');
let memberSize = $state(Number(data.themeSize) > 0 ? Number(data.themeSize) : THEME_SIZE_DEFAULT);
let memberOpacity = $state(
Number(data.themeOpacity) > 0 ? Number(data.themeOpacity) : THEME_OPACITY_DEFAULT
);
const colors = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6']; const colors = [
'#6366f1',
'#ec4899',
'#f59e0b',
'#10b981',
'#3b82f6',
'#ef4444',
'#8b5cf6',
'#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 />
@@ -23,8 +92,10 @@
</CardGrid> </CardGrid>
{:else} {:else}
<CardGrid> <CardGrid>
<Card cols={2} title="Appearance"> <!-- One form for appearance + theme; display:contents keeps the
cards as grid items. -->
<form <form
style="display:contents"
method="POST" method="POST"
action="?/update" action="?/update"
use:enhance={() => { use:enhance={() => {
@@ -35,9 +106,19 @@
if (result.type === 'success' || result.type === 'failure') { if (result.type === 'success' || result.type === 'failure') {
await applyAction(result); await applyAction(result);
} }
// Refresh session.pattern so the app background updates, and drop
// the live draft — the saved value is authoritative from here on.
if (result.type === 'success') {
themeDraft.pattern = null;
themeDraft.color = null;
themeDraft.size = null;
themeDraft.opacity = null;
await invalidateAll();
}
}; };
}} }}
> >
<Card cols={2} title="Appearance">
<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">
Display Name Display Name
@@ -59,35 +140,54 @@
{#each colors as c} {#each colors as c}
<button <button
type="button" type="button"
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor ? '3px solid #111827' : '2px solid transparent'};background:{c};cursor:pointer" style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor
onclick={() => memberColor = c} ? '3px solid #111827'
: '2px solid transparent'};background:{c};cursor:pointer"
onclick={() => {
memberColor = c;
themeDraft.color = c;
}}
></button> ></button>
{/each} {/each}
</div> </div>
<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'}
<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">
Email Email
</label> </label>
{#if data.session}
<input <input
type="email" type="email"
name="email" name="email"
bind:value={memberEmail} bind:value={memberEmail}
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box" style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
/> />
{:else}
<input
type="email"
name="email"
value={memberEmail}
disabled
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box;background:#f3f4f6;color:#9ca3af"
/>
{/if}
</div> </div>
{/if}
{#if form?.error} {#if form?.error}
<p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p> <p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p>
@@ -99,9 +199,105 @@
<div class="form-actions"> <div class="form-actions">
<Button type="submit" variant="primary">Update preferences</Button> <Button type="submit" variant="primary">Update preferences</Button>
</div> </div>
</form>
</Card> </Card>
<Card cols={1} title="Theme">
<p class="theme-hint">
Pick a background pattern — it's coloured with your chosen colour. None keeps the plain
background.
</p>
<PatternPicker
value={themeDraft.pattern ?? memberPattern}
color={memberColor}
size={themeDraft.size ?? memberSize}
opacity={themeDraft.opacity ?? memberOpacity}
onchange={(v) => {
memberPattern = v;
// Live preview on the app background before saving.
themeDraft.pattern = v;
themeDraft.color = memberColor;
}}
onsize={(v) => {
memberSize = v;
// Live scale preview before saving.
themeDraft.size = v;
}}
onopacity={(v) => {
memberOpacity = v;
// Live fade preview before saving.
themeDraft.opacity = v;
}}
/>
<input type="hidden" name="pattern" value={themeDraft.pattern ?? memberPattern} />
<input type="hidden" name="themeSize" value={themeDraft.size ?? memberSize} />
<input type="hidden" name="themeOpacity" value={themeDraft.opacity ?? memberOpacity} />
</Card>
</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.
@@ -119,4 +315,30 @@
padding-top: 1.25rem; padding-top: 1.25rem;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
} }
.theme-hint {
font-size: 0.85rem;
color: #6b7280;
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>
@@ -1,6 +1,24 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { servicesFor } from '$lib/server/servicesFor'; import { servicesFor } from '$lib/server/servicesFor';
// Per-member target-chore picks come in as `targetChore_<memberId>` form fields.
// Build the memberId → assignedChoreId map (empty values = count all of that
// member's chores, so they're dropped). `used` tells callers whether the
// per-member select UI was rendered at all (i.e. an individual-target reward),
// so the old scalar targetChoreId can be cleared on save.
function parseTargetChoreIds(fd: FormData): { map: Record<string, string>; used: boolean } {
const map: Record<string, string> = {};
let used = false;
for (const [k, v] of fd.entries()) {
if (k.startsWith('targetChore_')) {
used = true;
const memberId = k.slice('targetChore_'.length);
if (v) map[memberId] = String(v);
}
}
return { map, used };
}
export async function load(event) { export async function load(event) {
if (!event.locals.user) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId; const famId = event.locals.user.famId;
@@ -117,6 +135,9 @@ export const actions = {
if (data.occurrence === 'once') data.period = ''; if (data.occurrence === 'once') data.period = '';
const targetChoreId = fd.get('targetChoreId'); const targetChoreId = fd.get('targetChoreId');
if (targetChoreId !== null) data.targetChoreId = targetChoreId || ''; if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd);
if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds;
if (perMemberTargets) data.targetChoreId = '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -163,6 +184,9 @@ export const actions = {
if (occurrence === 'once') data.period = ''; if (occurrence === 'once') data.period = '';
const targetChoreId = fd.get('targetChoreId'); const targetChoreId = fd.get('targetChoreId');
if (targetChoreId !== null) data.targetChoreId = targetChoreId || ''; if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd);
if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds;
if (perMemberTargets) data.targetChoreId = '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -221,6 +245,9 @@ export const actions = {
startDate: fd.get('startDate') || '', startDate: fd.get('startDate') || '',
completeByDate: fd.get('completeByDate') || '' completeByDate: fd.get('completeByDate') || ''
}; };
const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd);
if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds;
if (perMemberTargets) data.targetChoreId = '';
if (data.occurrence === 'once') data.period = ''; if (data.occurrence === 'once') data.period = '';
try { try {
const s = servicesFor(event); const s = servicesFor(event);
@@ -6,8 +6,13 @@
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone'; import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone';
import {
computeBonusProgress,
type BonusConfigWithProgress as BonusProgressResult
} from '@shared/bonus-progress';
import { chevronRight } from '$lib/components/icons'; import { chevronRight } from '$lib/components/icons';
import Clock from '@lucide/svelte/icons/clock'; import Clock from '@lucide/svelte/icons/clock';
import Check from '@lucide/svelte/icons/check';
import Tag from '@lucide/svelte/icons/tag'; import Tag from '@lucide/svelte/icons/tag';
import UsersRound from '@lucide/svelte/icons/users-round'; import UsersRound from '@lucide/svelte/icons/users-round';
import Target from '@lucide/svelte/icons/target'; import Target from '@lucide/svelte/icons/target';
@@ -26,8 +31,8 @@
BonusConfig, BonusConfig,
BonusTemplate, BonusTemplate,
Member, Member,
BonusConfigWithProgress, AssignedChore,
BonusProgress, Completion,
Reward Reward
} from '$lib/types'; } from '$lib/types';
@@ -55,7 +60,12 @@
// Kanban columns can collapse (accordion) so templates can be dragged onto // Kanban columns can collapse (accordion) so templates can be dragged onto
// the desired area on tablet/mobile without cramming three columns. // the desired area on tablet/mobile without cramming three columns.
let collapsed = $state({ member: false, comp: false, collab: false }); let collapsed = $state({ member: false, comp: false, collab: false });
// `rewardsTargets` platform flag: OFF = simplified flow — rewards are always
// Individual, and the Competition/Collaboration columns become locked
// (faded, "Coming soon") row accordions instead of a 3-column grid.
let targetsEnabled = $derived(!!page.data.platformFlags?.rewardsTargets);
function toggleCol(key: 'member' | 'comp' | 'collab') { function toggleCol(key: 'member' | 'comp' | 'collab') {
if (!targetsEnabled && key !== 'member') return;
collapsed[key] = !collapsed[key]; collapsed[key] = !collapsed[key];
} }
@@ -90,7 +100,8 @@
completeBy: 'unlimited' as string, completeBy: 'unlimited' as string,
startDate: '' as string, startDate: '' as string,
completeByDate: '' as string, completeByDate: '' as string,
targetChoreId: '' as string targetChoreId: '' as string,
targetChoreIds: {} as Record<string, string>
}); });
let configs = $derived( let configs = $derived(
@@ -109,11 +120,33 @@
let allRewards = $derived( let allRewards = $derived(
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[]) || [] famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[]) || []
); );
let progressData = $state((data.progress as BonusConfigWithProgress[]) || []);
// Progress is recomputed in realtime from famStore (PB SSE) using the same
// shared algorithm as the server loader — otherwise the kanban (counts,
// bars, achieved states) goes stale until a page refresh.
let famPayday = $derived(Number(page.data.fam?.payday ?? (famStore.fam as any)?.payday ?? 1));
let famTz = $derived(
resolveTz(page.data.fam?.timezone || (famStore.fam as any)?.timezone || 'auto')
);
let progressData = $derived.by((): BonusProgressResult[] => {
if (!famStore.initialized) return (data.progress as BonusProgressResult[]) || [];
const active = (famStore.bonusConfigs as BonusConfig[]).filter((c) => c.status === 'active');
return computeBonusProgress(
active,
famStore.members as Member[],
famStore.assigned as AssignedChore[],
famStore.completions as Completion[],
famStore.rewards as Reward[],
famPayday,
famTz
);
});
let showPeriod = $derived(editVals.occurrence !== 'once'); let showPeriod = $derived(editVals.occurrence !== 'once');
let showCriteria = $derived(editVals.type !== 'manual'); let showCriteria = $derived(editVals.type !== 'manual');
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent'); let editIsPercent = $derived(
editVals.type === 'threshold' && editVals.thresholdType === 'percent'
);
// Standalone (no period) rewards can scope progress by completeBy + dates. // Standalone (no period) rewards can scope progress by completeBy + dates.
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period); let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
@@ -125,7 +158,28 @@
) )
); );
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); // Chores assigned to a particular member (plus shared chores, which every
// member can complete). Used for the dynamic per-member target-chore
// dropdowns when a count:chores reward is set to target "All (everyone)".
function choresForMember(memberId: string): AssignedChore[] {
return famStore.assigned.filter((a) => !a.isTodo && (!a.memberId || a.memberId === memberId));
}
function choreName(ac: AssignedChore): string {
return ac.customName || famStore.templateMap().get(ac.templateId || '')?.name || 'Chore';
}
function hasTargetChore(cfg: BonusConfig): boolean {
return !!cfg.targetChoreId || Object.values(cfg.targetChoreIds || {}).some(Boolean);
}
function editTargetChore(memberId: string, value: string) {
editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value };
}
let templateConfigs = $derived(
(templates as BonusTemplate[]).filter((t) => t.global && !t.isPocketMoney)
);
let allBonusConfigs = $derived( let allBonusConfigs = $derived(
configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[]
); );
@@ -136,7 +190,7 @@
); );
let progressByConfigId = $derived.by(() => { let progressByConfigId = $derived.by(() => {
const map = new Map<string, BonusConfigWithProgress>(); const map = new Map<string, BonusProgressResult>();
for (const p of progressData) map.set(p.config.id, p); for (const p of progressData) map.set(p.config.id, p);
return map; return map;
}); });
@@ -191,7 +245,7 @@
editVals = { editVals = {
name: tpl.name, name: tpl.name,
description: tpl.description || '', description: tpl.description || '',
target: targetType, target: !targetsEnabled ? 'individual' : targetType,
memberId: '', memberId: '',
type: tpl.type, type: tpl.type,
thresholdType: tpl.thresholdType || 'points', thresholdType: tpl.thresholdType || 'points',
@@ -204,7 +258,8 @@
completeBy: 'unlimited', completeBy: 'unlimited',
startDate: todayIso(), startDate: todayIso(),
completeByDate: '', completeByDate: '',
targetChoreId: '' targetChoreId: '',
targetChoreIds: {}
}; };
step = 1; step = 1;
showEditModal = true; showEditModal = true;
@@ -241,10 +296,21 @@
function openEditConfig(cfg: BonusConfig) { function openEditConfig(cfg: BonusConfig) {
editingConfig = cfg; editingConfig = cfg;
creatingFromTemplate = null; creatingFromTemplate = null;
const targetIds = (cfg.targetChoreIds as Record<string, string> | undefined) || {};
// Legacy individual rewards pinned a chore via the scalar targetChoreId —
// surface that in the per-member dropdown too.
if (
cfg.target === 'individual' &&
cfg.memberId &&
cfg.targetChoreId &&
!targetIds[cfg.memberId]
) {
targetIds[cfg.memberId] = cfg.targetChoreId;
}
editVals = { editVals = {
name: cfg.name, name: cfg.name,
description: cfg.description || '', description: cfg.description || '',
target: cfg.target, target: !targetsEnabled ? 'individual' : cfg.target,
memberId: cfg.memberId || '', memberId: cfg.memberId || '',
type: cfg.type, type: cfg.type,
thresholdType: cfg.thresholdType || 'points', thresholdType: cfg.thresholdType || 'points',
@@ -257,7 +323,8 @@
completeBy: cfg.completeBy || 'unlimited', completeBy: cfg.completeBy || 'unlimited',
startDate: cfg.startDate || todayIso(), startDate: cfg.startDate || todayIso(),
completeByDate: cfg.completeByDate || '', completeByDate: cfg.completeByDate || '',
targetChoreId: cfg.targetChoreId || '' targetChoreId: cfg.targetChoreId || '',
targetChoreIds: targetIds
}; };
step = 1; step = 1;
showEditModal = true; showEditModal = true;
@@ -269,6 +336,10 @@
let doneConfigs = $derived( let doneConfigs = $derived(
configs.filter((c: BonusConfig) => c.status === 'completed') as BonusConfig[] configs.filter((c: BonusConfig) => c.status === 'completed') as BonusConfig[]
); );
let isPocketMoney = $derived(
!!editingConfig?.isPocketMoney || !!creatingFromTemplate?.isPocketMoney
);
</script> </script>
<ViewHeader title="Rewards" hero /> <ViewHeader title="Rewards" hero />
@@ -280,12 +351,15 @@
<strong>Pocket Money</strong> <strong>Pocket Money</strong>
{#if hasUnsetPocket} {#if hasUnsetPocket}
<p> <p>
Every child has a <em>Pocket Money</em> droplet. Set how it's awarded — pick the Every child has a <em>Pocket Money</em> droplet. Set how it's awarded — pick the amount (£)
amount (£) and the % of weekly chores they must complete to earn it — then save. Until and the % of weekly chores they must complete to earn it — then save. Until you set the amount,
you set the amount, pocket money is paused for that child. pocket money is paused for that child.
</p> </p>
{:else} {:else}
<p>All Pocket Money droplets are set. Children will earn pocket money as they complete chores.</p> <p>
All Pocket Money droplets are set. Children will earn pocket money as they complete
chores.
</p>
{/if} {/if}
</div> </div>
</Card> </Card>
@@ -298,7 +372,12 @@
<h3>Templates — Drag to assign</h3> <h3>Templates — Drag to assign</h3>
<div class="template-list"> <div class="template-list">
{#each templateConfigs as cfg} {#each templateConfigs as cfg}
<div class="template-card" draggable="true" ondragstart={(e) => handleDragStart(e, cfg)} style="border-left-color:{cfg.color}"> <div
class="template-card"
draggable="true"
ondragstart={(e) => handleDragStart(e, cfg)}
style="border-left-color:{cfg.color}"
>
<div class="card-head"> <div class="card-head">
<TemplateIcon name={cfg.icon} size={18} color={cfg.color} /> <TemplateIcon name={cfg.icon} size={18} color={cfg.color} />
<strong>{cfg.name}</strong> <strong>{cfg.name}</strong>
@@ -326,9 +405,14 @@
</div> </div>
</Card> </Card>
<Card cols={3} scrollX> <Card cols={3} scrollX>
<div class="kanban"> <div class="kanban" class:rows={!targetsEnabled}>
<!-- Member Column --> <!-- Member Column -->
<div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}> <div
class="column member-col"
class:collapsed={collapsed.member}
ondragover={handleDragOver}
ondrop={handleDropToMember}
>
<button class="col-header" type="button" onclick={() => toggleCol('member')}> <button class="col-header" type="button" onclick={() => toggleCol('member')}>
<span class="col-title">Member</span> <span class="col-title">Member</span>
<span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span> <span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span>
@@ -354,14 +438,14 @@
{/if} {/if}
</div> </div>
<span class="reward-total {cfg.rewardType}"> <span class="reward-total {cfg.rewardType}">
{cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} {formatReward(cfg.rewardType, cfg.rewardValue)}
</span> </span>
</div> </div>
{#each members as m} {#each members as m}
{#if !cfg.memberId || cfg.memberId === m.id} {#if !cfg.memberId || cfg.memberId === m.id}
{@const p = progressByConfigId {@const p = progressByConfigId
.get(cfg.id) .get(cfg.id)
?.progress.find((pr: BonusProgress) => pr.memberId === m.id)} ?.progress.find((pr) => pr.memberId === m.id)}
{#if cfg.type === 'manual'} {#if cfg.type === 'manual'}
<div class="member-progress"> <div class="member-progress">
<span class="mname" style="color:{m.color}">{m.name}</span> <span class="mname" style="color:{m.color}">{m.name}</span>
@@ -376,8 +460,13 @@
: criteria > 0 : criteria > 0
? Math.min(100, (current / criteria) * 100) ? Math.min(100, (current / criteria) * 100)
: 0} : 0}
<div class="member-progress"> <div class="member-progress" class:done={achieved}>
<span class="mname" style="color:{m.color}">{m.name}</span> <span class="mname" style="color:{m.color}">{m.name}</span>
{#if achieved}
<span class="done-chip">
<Check size={13} /> Done · {formatReward(cfg.rewardType, cfg.rewardValue)}
</span>
{:else}
{#if criteria > 0} {#if criteria > 0}
<div class="bar-wrap"> <div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%" class:met={achieved}></div> <div class="bar-fill" style="width:{pct}%" class:met={achieved}></div>
@@ -385,18 +474,21 @@
{/if} {/if}
{#if cfg.type === 'count'} {#if cfg.type === 'count'}
<span class="count-progress"> <span class="count-progress">
{Math.min(current, criteria)} / {criteria}{cfg.targetChoreId ? ' chores' : ''} {Math.min(current, criteria)} / {criteria}{hasTargetChore(cfg)
? ' chores'
: ''}
</span> </span>
{/if} {/if}
<span class="stat {p?.state ?? 'pending'}"> <span class="stat {p?.state ?? 'pending'}">
{#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if} {#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if}
</span> </span>
{/if}
</div> </div>
{/if} {/if}
{/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}
@@ -407,10 +499,21 @@
</div> </div>
<!-- Competition Column --> <!-- Competition Column -->
<div class="column comp-col" class:collapsed={collapsed.comp} ondragover={handleDragOver} ondrop={handleDropToCompetition}> <div
class="column comp-col"
class:locked={!targetsEnabled}
class:collapsed={targetsEnabled ? collapsed.comp : true}
ondragover={targetsEnabled ? handleDragOver : undefined}
ondrop={targetsEnabled ? handleDropToCompetition : undefined}
>
<button class="col-header" type="button" onclick={() => toggleCol('comp')}> <button class="col-header" type="button" onclick={() => toggleCol('comp')}>
<span class="col-title">Competition</span> <span class="col-title">Competition</span>
<span class="chev" class:open={!collapsed.comp}>{@html chevronRight}</span> {#if !targetsEnabled}
<span class="soon-badge">Coming soon</span>
{/if}
<span class="chev" class:open={targetsEnabled ? !collapsed.comp : false}
>{@html chevronRight}</span
>
</button> </button>
<div class="col-body"> <div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
@@ -431,7 +534,7 @@
<span class="badge">{cfg.occurrence}</span> <span class="badge">{cfg.occurrence}</span>
</div> </div>
<span class="reward-total {cfg.rewardType}"> <span class="reward-total {cfg.rewardType}">
{cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} {formatReward(cfg.rewardType, cfg.rewardValue)}
</span> </span>
</div> </div>
{#if prog} {#if prog}
@@ -462,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}
@@ -473,10 +576,21 @@
</div> </div>
<!-- Collaboration Column --> <!-- Collaboration Column -->
<div class="column collab-col" class:collapsed={collapsed.collab} ondragover={handleDragOver} ondrop={handleDropToCollaboration}> <div
class="column collab-col"
class:locked={!targetsEnabled}
class:collapsed={targetsEnabled ? collapsed.collab : true}
ondragover={targetsEnabled ? handleDragOver : undefined}
ondrop={targetsEnabled ? handleDropToCollaboration : undefined}
>
<button class="col-header" type="button" onclick={() => toggleCol('collab')}> <button class="col-header" type="button" onclick={() => toggleCol('collab')}>
<span class="col-title">Collaboration</span> <span class="col-title">Collaboration</span>
<span class="chev" class:open={!collapsed.collab}>{@html chevronRight}</span> {#if !targetsEnabled}
<span class="soon-badge">Coming soon</span>
{/if}
<span class="chev" class:open={targetsEnabled ? !collapsed.collab : false}
>{@html chevronRight}</span
>
</button> </button>
<div class="col-body"> <div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
@@ -497,7 +611,7 @@
<span class="badge">{cfg.occurrence}</span> <span class="badge">{cfg.occurrence}</span>
</div> </div>
<span class="reward-total {cfg.rewardType}"> <span class="reward-total {cfg.rewardType}">
{cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} {formatReward(cfg.rewardType, cfg.rewardValue)}
</span> </span>
</div> </div>
{#if prog} {#if prog}
@@ -530,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}
@@ -564,20 +678,20 @@
type="button" type="button"
class="step-tab" class="step-tab"
class:active={step === 1} class:active={step === 1}
onclick={() => (step = 1)} onclick={() => (step = 1)}>{@html stepIcon(1)}<span>Who</span></button
>{@html stepIcon(1)}<span>Who</span></button> >
<button <button
type="button" type="button"
class="step-tab" class="step-tab"
class:active={step === 2} class:active={step === 2}
onclick={() => (step = 2)} onclick={() => (step = 2)}>{@html stepIcon(2)}<span>How</span></button
>{@html stepIcon(2)}<span>How</span></button> >
<button <button
type="button" type="button"
class="step-tab" class="step-tab"
class:active={step === 3} class:active={step === 3}
onclick={() => (step = 3)} onclick={() => (step = 3)}>{@html stepIcon(3)}<span>What</span></button
>{@html stepIcon(3)}<span>What</span></button> >
</div> </div>
<form <form
@@ -589,7 +703,11 @@
<input type="hidden" name="id" value={editingConfig?.id} /> <input type="hidden" name="id" value={editingConfig?.id} />
{/if} {/if}
{#if creatingFromTemplate} {#if creatingFromTemplate}
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} /> <input
type="hidden"
name="isPocketMoney"
value={creatingFromTemplate.isPocketMoney ? '1' : ''}
/>
{/if} {/if}
<!-- STEP 1 — Who (basics) --> <!-- STEP 1 — Who (basics) -->
@@ -598,10 +716,13 @@
<span class="field-label">{@html stepIcon(1)} Name</span> <span class="field-label">{@html stepIcon(1)} Name</span>
<input name="name" bind:value={editVals.name} required /> <input name="name" bind:value={editVals.name} required />
</label> </label>
{#if !isPocketMoney}
<label> <label>
<span class="field-label">{@html stepIcon(1)} Description</span> <span class="field-label">{@html stepIcon(1)} Description</span>
<input name="description" bind:value={editVals.description} /> <input name="description" bind:value={editVals.description} />
</label> </label>
{/if}
{#if targetsEnabled}
<label> <label>
<span class="field-label"><UsersRound size={14} /> Target (who)</span> <span class="field-label"><UsersRound size={14} /> Target (who)</span>
<select name="target" bind:value={editVals.target}> <select name="target" bind:value={editVals.target}>
@@ -610,6 +731,10 @@
<option value="collaborative">Collaborative</option> <option value="collaborative">Collaborative</option>
</select> </select>
</label> </label>
{:else}
<input type="hidden" name="target" value="individual" />
<p class="hint">Rewards are always created for individual members.</p>
{/if}
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'} {#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
<label> <label>
<span class="field-label"><UsersRound size={14} /> Member</span> <span class="field-label"><UsersRound size={14} /> Member</span>
@@ -625,6 +750,7 @@
<!-- STEP 2 — How (rules + progress) --> <!-- STEP 2 — How (rules + progress) -->
<div class="step-body" hidden={step !== 2}> <div class="step-body" hidden={step !== 2}>
{#if !isPocketMoney}
<label> <label>
<span class="field-label"><Settings2 size={14} /> Type</span> <span class="field-label"><Settings2 size={14} /> Type</span>
<select name="type" bind:value={editVals.type}> <select name="type" bind:value={editVals.type}>
@@ -633,7 +759,8 @@
<option value="manual">Manual</option> <option value="manual">Manual</option>
</select> </select>
</label> </label>
{#if editVals.type === 'threshold'} {/if}
{#if editVals.type === 'threshold' && !isPocketMoney}
<label> <label>
<span class="field-label"><Target size={14} /> Threshold by</span> <span class="field-label"><Target size={14} /> Threshold by</span>
<select name="thresholdType" bind:value={editVals.thresholdType}> <select name="thresholdType" bind:value={editVals.thresholdType}>
@@ -675,14 +802,63 @@
{/if} {/if}
{/if} {/if}
{#if editVals.type === 'count'} {#if editVals.type === 'count'}
{#if editVals.target === 'individual'}
{#if editVals.memberId}
{@const m = members.find((x) => x.id === editVals.memberId)}
{@const chores = choresForMember(editVals.memberId)}
<label>
<span class="field-label"
><Target size={14} /> Target Chore
{m?.name ? `(${m.name})` : ''}</span
>
<select
name="targetChore_{editVals.memberId}"
value={editVals.targetChoreIds[editVals.memberId] || ''}
onchange={(e) => editTargetChore(editVals.memberId!, e.currentTarget.value)}
>
<option value="">All of {m?.name || 'this member'}’s chores</option>
{#each chores as ac}
<option value={ac.id}>{choreName(ac)}</option>
{/each}
</select>
<span class="hint"
>Count completions of a specific chore for {m?.name || 'this member'} only. Leave
as "All..." to count every chore they’re assigned.</span
>
</label>
{:else}
<p class="hint member-target-hint">
Pick a target chore per member — each child counts their own assigned chores.
</p>
{#each members as m}
{@const chores = choresForMember(m.id)}
<label>
<span class="field-label"
><UsersRound size={14} /> {m.name} — Target Chore</span
>
<select
name="targetChore_{m.id}"
value={editVals.targetChoreIds[m.id] || ''}
onchange={(e) => editTargetChore(m.id, e.currentTarget.value)}
>
<option value="">All of {m.name}’s chores</option>
{#each chores as ac}
<option value={ac.id}>{choreName(ac)}</option>
{/each}
</select>
</label>
{/each}
{#if members.length === 0}
<p class="empty">No members yet</p>
{/if}
{/if}
{:else}
<label> <label>
<span class="field-label"><Target size={14} /> Target Chore</span> <span class="field-label"><Target size={14} /> Target Chore</span>
<select name="targetChoreId" bind:value={editVals.targetChoreId}> <select name="targetChoreId" bind:value={editVals.targetChoreId}>
<option value="">All Chores</option> <option value="">All Chores</option>
{#each targetChoreOptions as ac} {#each targetChoreOptions as ac}
<option value={ac.id}> <option value={ac.id}>{choreName(ac)}</option>
{ac.customName || famStore.templateMap().get(ac.templateId || '')?.name || 'Chore'}
</option>
{/each} {/each}
</select> </select>
<span class="hint" <span class="hint"
@@ -691,6 +867,8 @@
> >
</label> </label>
{/if} {/if}
{/if}
{#if !isPocketMoney}
<label> <label>
<span class="field-label"><Settings2 size={14} /> Occurrence</span> <span class="field-label"><Settings2 size={14} /> Occurrence</span>
<select name="occurrence" bind:value={editVals.occurrence}> <select name="occurrence" bind:value={editVals.occurrence}>
@@ -698,7 +876,8 @@
<option value="once">Once</option> <option value="once">Once</option>
</select> </select>
</label> </label>
{#if showPeriod} {/if}
{#if showPeriod && !isPocketMoney}
<label> <label>
<span class="field-label"><CalendarDays size={14} /> Period</span> <span class="field-label"><CalendarDays size={14} /> Period</span>
<select name="period" bind:value={editVals.period}> <select name="period" bind:value={editVals.period}>
@@ -708,8 +887,8 @@
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</select> </select>
<span class="hint" <span class="hint"
>Recurring bonuses reset and re-earn each period. Leave as "None" for a >Recurring bonuses reset and re-earn each period. Leave as "None" for a one-off
one-off reward measured once.</span reward measured once.</span
> >
</label> </label>
{/if} {/if}
@@ -717,7 +896,8 @@
<div class="start-mode"> <div class="start-mode">
<label <label
><span class="field-label"><CalendarDays size={14} /> Progress counts</span ><span class="field-label"><CalendarDays size={14} /> Progress counts</span
></label> ></label
>
<div class="radio-group"> <div class="radio-group">
<label> <label>
<input <input
@@ -741,15 +921,17 @@
{#if editVals.completeBy === 'custom'} {#if editVals.completeBy === 'custom'}
<label> <label>
<span class="field-label"><CalendarDays size={14} /> Complete by</span> <span class="field-label"><CalendarDays size={14} /> Complete by</span>
<input type="date" name="completeByDate" bind:value={editVals.completeByDate} /> <input
type="date"
name="completeByDate"
bind:value={editVals.completeByDate}
/>
</label> </label>
{/if} {/if}
<label> <label>
<span class="field-label"><CalendarDays size={14} /> Progress starts on</span> <span class="field-label"><CalendarDays size={14} /> Progress starts on</span>
<input type="date" name="startDate" bind:value={editVals.startDate} /> <input type="date" name="startDate" bind:value={editVals.startDate} />
<span class="hint" <span class="hint">Points before this date won't count toward the reward.</span>
>Points before this date won't count toward the reward.</span
>
</label> </label>
</div> </div>
{/if} {/if}
@@ -798,7 +980,8 @@
<div class="start-mode"> <div class="start-mode">
<label <label
><span class="field-label"><CalendarDays size={14} /> When to start</span ><span class="field-label"><CalendarDays size={14} /> When to start</span
></label> ></label
>
<div class="radio-group"> <div class="radio-group">
<label> <label>
<input <input
@@ -847,7 +1030,7 @@
<button type="button" class="nav-btn" onclick={() => (step = 2)}> <button type="button" class="nav-btn" onclick={() => (step = 2)}>
<ArrowLeft size={14} /> Back <ArrowLeft size={14} /> Back
</button> </button>
{#if editingConfig} {#if editingConfig && !isPocketMoney}
<button <button
type="button" type="button"
class="toggle-btn" class="toggle-btn"
@@ -898,7 +1081,8 @@
</button> </button>
{/if} {/if}
<button type="submit" class="save-btn"> <button type="submit" class="save-btn">
<Save size={14} /> {creatingFromTemplate ? 'Assign' : 'Save'} <Save size={14} />
{creatingFromTemplate ? 'Assign' : 'Save'}
</button> </button>
{:else} {:else}
<button type="button" class="next-btn" onclick={() => (step = 2)}> <button type="button" class="next-btn" onclick={() => (step = 2)}>
@@ -972,9 +1156,18 @@
min-height: 200px; min-height: 200px;
background: #fafbff; background: #fafbff;
} }
.member-col { border-top-color: #6366f1; background: #eef2ff; } .member-col {
.comp-col { border-top-color: #f59e0b; background: #fffbeb; } border-top-color: #6366f1;
.collab-col { border-top-color: #10b981; background: #ecfdf5; } background: #eef2ff;
}
.comp-col {
border-top-color: #f59e0b;
background: #fffbeb;
}
.collab-col {
border-top-color: #10b981;
background: #ecfdf5;
}
.column h3 { .column h3 {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.95rem; font-size: 0.95rem;
@@ -1010,6 +1203,33 @@
.column.collapsed .col-body { .column.collapsed .col-body {
display: none; display: none;
} }
.kanban.rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.kanban.rows .column {
width: 100%;
}
.column.locked {
opacity: 0.55;
pointer-events: none;
}
.column.locked .col-header {
cursor: not-allowed;
}
.soon-badge {
margin-left: auto;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: #e5e7eb;
color: #6b7280;
white-space: nowrap;
}
.card-head { .card-head {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1095,7 +1315,10 @@
padding: 0.65rem 0.85rem; padding: 0.65rem 0.85rem;
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
cursor: pointer; cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
} }
.col-card:hover { .col-card:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -1151,8 +1374,26 @@
.member-progress { .member-progress {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.5rem;
margin-bottom: 0.3rem; padding: 0.25rem 0;
}
.member-progress.done {
background: #f0fdf4;
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
.done-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 700;
color: #047857;
background: #d1fae5;
border: 1px solid #a7f3d0;
border-radius: 999px;
padding: 2px 10px;
white-space: nowrap;
} }
.mname { .mname {
font-size: 0.8rem; font-size: 0.8rem;
@@ -7,6 +7,8 @@ import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
import { sendParentInviteEmail } from '$lib/server/email'; 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 { getPin, resetPin as resetChildPin } from '$lib/server/pins';
import { import {
createBillingPortalSession, createBillingPortalSession,
cancelSubscriptionAtPeriodEnd, cancelSubscriptionAtPeriodEnd,
@@ -18,7 +20,23 @@ function famIdOf(event: RequestEvent): string {
return event.locals.user.famId; return event.locals.user.famId;
} }
// In demo mode, settings is disabled for all users.
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
}
export async function load(event: RequestEvent) { export async function load(event: RequestEvent) {
if (await isDemoMode()) {
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);
const [members, fam, seasons] = await Promise.all([ const [members, fam, seasons] = await Promise.all([
@@ -39,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 = {
@@ -150,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',
@@ -141,14 +161,11 @@
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 }); qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
} }
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
}
function handleInviteResult(result: any) { function handleInviteResult(result: any) {
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.`
@@ -205,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"
@@ -214,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">
@@ -230,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"
@@ -261,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}>
@@ -273,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">
@@ -292,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">
@@ -367,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>
@@ -374,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"
@@ -383,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>
@@ -482,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
@@ -496,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
@@ -522,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>
@@ -595,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();
}; };
@@ -606,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}
@@ -1165,4 +1319,9 @@
.resume-hint { .resume-hint {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@media (max-width: 639px) {
.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)}`);
} }
}; };
+21 -3
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>
@@ -14,9 +15,18 @@
<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>
{#if joined}
<JoinPinFlow
targetUrl={`/${famSlug}/${encodeURIComponent(joined.username)}`}
force={joined.hasOtherKids}
/>
{:else}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1> <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500"> <p class="mt-1 text-sm text-slate-500">
Enter your name and the code your parent gave you to get started. Enter your name and the code your parent gave you to get started.
@@ -30,6 +40,13 @@
if (result.type === 'failure') { if (result.type === 'failure') {
name = (result.data as any)?.name || ''; name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || ''; 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(); await update();
}; };
@@ -51,7 +68,7 @@
maxlength="6" maxlength="6"
placeholder="6-digit code" placeholder="6-digit code"
autocomplete="one-time-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" 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} {#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p> <p class="text-sm font-medium text-rose-600">{form.error}</p>
@@ -62,6 +79,7 @@
<p class="mt-6 text-xs text-slate-400"> <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. Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p> </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); clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token); 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);
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,6 +25,12 @@
> >
<span class="home-badge">{@html homeIcon}</span> <span class="home-badge">{@html homeIcon}</span>
</div> </div>
{#if joined}
<JoinPinFlow
targetUrl={`/${famSlug}/${encodeURIComponent(username || '')}`}
force={joined.hasOtherKids}
/>
{:else}
{#if isParent} {#if isParent}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1> <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500"> <p class="mt-1 text-sm text-slate-500">
@@ -33,8 +40,8 @@
{:else} {:else}
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1> <h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
<p class="mt-1 text-sm text-slate-500"> <p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent gave Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
you to get started. gave you to get started.
</p> </p>
{/if} {/if}
@@ -44,8 +51,16 @@
use:enhance={() => { use:enhance={() => {
return async ({ result, update }) => { return async ({ result, update }) => {
if (result.type === 'failure') { if (result.type === 'failure') {
otp = (result.data as any)?.otp || ''; 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(); await update();
}; };
}} }}
@@ -58,7 +73,7 @@
maxlength="6" maxlength="6"
placeholder="6-digit code" placeholder="6-digit code"
autocomplete="one-time-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" 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} {#if isParent}
<input <input
@@ -91,6 +106,7 @@
<p class="mt-6 text-xs text-slate-400"> <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. Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p> </p>
{/if}
</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>
@@ -89,12 +89,15 @@ export const load: PageServerLoad = async (event) => {
pbAdmin.getList('bonus_templates', 'global = true') pbAdmin.getList('bonus_templates', 'global = true')
]); ]);
const platformFlags = await getPlatformFlags();
return { return {
authenticated: true, authenticated: true,
fams: famsWithStats, fams: famsWithStats,
codes: codeList, codes: codeList,
choreTemplates: choreTemplates as any[], choreTemplates: choreTemplates as any[],
bonusTemplates: bonusTemplates as any[], bonusTemplates: bonusTemplates as any[],
platformFlags,
totalFams: fams.length, totalFams: fams.length,
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0), totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0), totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
@@ -121,6 +124,7 @@ export const load: PageServerLoad = async (event) => {
codeCount: 0, codeCount: 0,
choreTemplates: [], choreTemplates: [],
bonusTemplates: [], bonusTemplates: [],
platformFlags: {},
loadError: e instanceof Error ? e.message : 'Failed to load platform data' loadError: e instanceof Error ? e.message : 'Failed to load platform data'
}; };
} }
+33
View File
@@ -188,6 +188,34 @@
</button> </button>
</form> </form>
</div> </div>
<div class="flag-row">
<span class="flag-name">demo</span>
<span class="flag-desc">Showboaters demo family with sample data for prospectus viewers</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="demo" />
<button
type="submit"
class="toggle-btn"
class:active={page.data.platformFlags?.demo}
>
{page.data.platformFlags?.demo ? 'ON' : 'OFF'}
</button>
</form>
</div>
<div class="flag-row">
<span class="flag-name">rewardsTargets</span>
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="rewardsTargets" />
<button
type="submit"
class="toggle-btn"
class:active={page.data.platformFlags?.rewardsTargets}
>
{page.data.platformFlags?.rewardsTargets ? 'ON' : 'OFF'}
</button>
</form>
</div>
</Card> </Card>
</CardGrid> </CardGrid>
@@ -923,6 +951,11 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #374151; color: #374151;
} }
.flag-desc {
font-size: 0.7rem;
color: #9ca3af;
text-align: center;
}
.toggle-btn { .toggle-btn {
background: #e5e7eb; background: #e5e7eb;
+2 -1
View File
@@ -34,7 +34,8 @@ export async function POST(event: RequestEvent) {
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) }) ? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
: await s.chat.send(famId, actor, { : await s.chat.send(famId, actor, {
content: body.content || '', content: body.content || '',
clientId: body.clientId || '' clientId: body.clientId || '',
siteUrl: event.url.origin
}); });
return json(data); return json(data);
} catch (e) { } catch (e) {
@@ -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 });
}
+1 -1
View File
@@ -43,7 +43,7 @@ export async function POST({ request, url }) {
const resetUrl = `${url.origin}/login/verify/${token}`; const resetUrl = `${url.origin}/login/verify/${token}`;
await resend.emails.send({ await resend.emails.send({
from: 'no-reply@famchamp.ai', from: 'no-reply@walthamstow.xyz',
to: email, to: email,
subject: 'Reset your FamDone password', subject: 'Reset your FamDone password',
html: ` html: `
@@ -1,7 +1,9 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services'; import { createServices } from '$lib/server/services';
import { sendClaimRequestEmail } from '$lib/server/email';
import { handleOf } from '@shared/slugify';
export async function POST(event: RequestEvent) { export async function POST(event: RequestEvent) {
const u = event.locals.user; const u = event.locals.user;
@@ -10,7 +12,35 @@ export async function POST(event: RequestEvent) {
const id = event.params.id!; const id = event.params.id!;
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.rewards.claim(u.famId, id)); const record = await s.rewards.claim(u.famId, id);
// Tell the parent(s) a claim was requested — best-effort, so a mail
// outage can never fail the claim itself.
try {
const [parents, fam] = await Promise.all([
pbAdmin.getList('users', `famId = '${u.famId}' && role = 'parent'`),
pbAdmin.getOne('fams', u.famId)
]);
const label = record?.label || 'a reward';
const idx = label.indexOf(' – ');
const rewardName = idx === -1 ? label : label.slice(0, idx);
const rewardValue = idx === -1 ? '' : label.slice(idx + 3);
for (const parent of parents) {
if (!parent.email) continue;
await sendClaimRequestEmail({
to: parent.email,
parentName: parent.name || 'there',
childName: u.name || 'Someone',
rewardName,
rewardValue,
dashboardUrl: `${event.url.origin}/${fam?.slug || u.famId}/${handleOf(parent.username)}`
});
}
} catch {
// Best-effort only — the claim already succeeded.
}
return json(record);
} catch (e) { } catch (e) {
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 }); return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
} }
+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)}`);
} }
}; };
+24 -2
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,9 +14,18 @@
<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>
{#if joined}
<JoinPinFlow
targetUrl={`/${joined.famSlug}/${encodeURIComponent(joined.username)}`}
force={joined.hasOtherKids}
/>
{:else}
<h1 class="text-xl font-bold text-slate-900">Join the family</h1> <h1 class="text-xl font-bold text-slate-900">Join the family</h1>
<p class="mt-1 text-sm text-slate-500"> <p class="mt-1 text-sm text-slate-500">
Enter your family name and the code your parent gave you to get started. Enter your family name and the code your parent gave you to get started.
@@ -30,6 +40,17 @@
family = (result.data as any)?.family || ''; family = (result.data as any)?.family || '';
name = (result.data as any)?.name || ''; name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || ''; 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(); await update();
}; };
@@ -70,6 +91,7 @@
<p class="mt-6 text-xs text-slate-400"> <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. Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p> </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') {
+20 -5
View File
@@ -34,11 +34,7 @@
} }
</script> </script>
<AuthShell <AuthShell title="Log in" subtitle="Welcome back to FamDone." secondary={secondaryChild}>
title="Log in"
subtitle="Welcome back to FamDone."
secondary={secondaryChild}
>
{#if showForgot} {#if showForgot}
{#if forgotSuccess} {#if forgotSuccess}
<p class="form-ok"> <p class="form-ok">
@@ -93,6 +89,10 @@
Forgot password? Forgot password?
</button> </button>
<button type="submit">Log in</button> <button type="submit">Log in</button>
<p class="privacy-note">
We use a cookie to keep you signed in and collect only what we need to run your account. See
our <a href="/privacy">Privacy Policy</a>.
</p>
</form> </form>
<p class="alt"> <p class="alt">
@@ -194,6 +194,21 @@
color: #6b7280; color: #6b7280;
text-align: center; text-align: center;
} }
.privacy-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #6b7280;
line-height: 1.5;
text-align: center;
}
.privacy-note a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.privacy-note a:hover {
text-decoration: underline;
}
.alt a { .alt a {
color: #4338ca; color: #4338ca;
text-decoration: none; text-decoration: none;
+3 -6
View File
@@ -1,14 +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 function load(event) {
signOut(event);
throw redirect(303, '/');
} }
export const actions = { export const actions = {
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import Footer from '$lib/components/Footer.svelte';
import FamDone from '$lib/components/FamDone.svelte';
</script>
<svelte:head>
<title>Privacy Policy — FamDone</title>
<meta
name="description"
content="How FamDone collects, uses, and protects your personal data — in plain, GDPR-friendly language."
/>
</svelte:head>
<main class="privacy">
<a class="brand" href="/"><FamDone /></a>
<div class="card">
<h1>Privacy Policy</h1>
<p class="updated">Last updated: September 2026</p>
<section>
<h2>1. Who we are (the controller)</h2>
<p>
<FamDone /> is provided by <strong>threejjjs / Interactive Tech</strong>. We are the "data
controller" for the personal data you give us. If you have any questions about this policy
or your data, you can reach us at
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>.
</p>
</section>
<section>
<h2>2. What data we collect</h2>
<p>We keep things minimal. When you create an account we collect:</p>
<ul>
<li><strong>Email address</strong> — used only as your login identity.</li>
<li>
<strong>Usernames and family/chore names</strong> — so your account and your family's chores
work.
</li>
</ul>
<p>
We do <strong>not</strong> collect any sensitive data (race, health, religion, biometrics, etc.),
and we do not use your data for profiling, advertising, or predictions about you.
</p>
</section>
<section>
<h2>3. Why we collect it (the purpose &amp; legal basis)</h2>
<p>
We process your data solely to provide the service you asked for: to create and maintain
your account, and to authenticate you when you log in. The legal basis for this is <strong
>contract necessity</strong
> (Article 6(1)(b) GDPR) — we process the data needed to run the account you signed up for.
</p>
</section>
<section>
<h2>4. How long we keep it</h2>
<p>
We keep your data for as long as your account is active. If you delete your account, (see
"Your rights" below) we delete or anonymise your personal data in a reasonable timeframe,
unless we're legally required to keep it longer.
</p>
</section>
<section>
<h2>5. Do we share or sell your data?</h2>
<p>
<strong>No.</strong> We do not sell, rent, or trade your personal data, and we do not share it
with third parties for their own marketing purposes. We only share the data that's strictly necessary
to run the service (for example, processing a payment you make), and we use your email only to
sign you in.
</p>
</section>
<section>
<h2>6. Cookies and login</h2>
<p>
We use a strictly-necessary session/login cookie so you can stay signed in. This is
essential for the service to work and does not require separate consent. We do not use
cookies for advertising, analytics, or marketing pixels.
</p>
</section>
<section>
<h2>7. Your rights</h2>
<p>Under the GDPR you have the right to:</p>
<ul>
<li><strong>Access</strong> the personal data we hold about you.</li>
<li><strong>Rectify</strong> inaccurate data.</li>
<li>Request <strong>deletion</strong> ("right to be forgotten") of your data.</li>
<li><strong>Portability</strong> — receive your data in a machine-readable format.</li>
<li>Withdraw consent or object to processing where it applies.</li>
</ul>
<p>
To exercise any of these rights, email
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>.
</p>
</section>
<section>
<h2>8. International transfers</h2>
<p>
We store your data on our secure hosting providers. If your data is ever transferred outside
the European Economic Area (EEA), we rely on recognised safeguards (such as the relevant
adequacy decisions or the EU's Standard Contractual Clauses) to protect it.
</p>
</section>
<section>
<h2>9. Complaints</h2>
<p>
If you believe we've handled your data incorrectly, please contact us first at
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>. You also have the right to
lodge a complaint with your local supervisory authority at any time.
</p>
</section>
<section>
<h2>10. Children's data</h2>
<p>
A family can add child members under a parent's account. We only keep the child's chosen
username so their chores and progress work. As a parent you control this data and can change
or delete it from your family settings or by contacting us.
</p>
</section>
<section>
<h2>11. Contact</h2>
<p>
Questions about this policy or your data? Email
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>.
</p>
</section>
</div>
<Footer />
</main>
<style>
main {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem 0;
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
font-family:
system-ui,
-apple-system,
sans-serif;
color: #1f2937;
}
.brand {
font-size: 1.15rem;
font-weight: 700;
color: #4338ca;
text-decoration: none;
margin-bottom: 1.5rem;
}
.card {
width: 100%;
max-width: 720px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08);
padding: 2.5rem;
}
.card h1 {
margin: 0 0 0.25rem;
font-size: 1.75rem;
}
.updated {
margin: 0 0 2rem;
color: #6b7280;
font-size: 0.85rem;
}
.card section {
margin: 0 0 1.75rem;
}
.card section h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: #111827;
}
.card p {
margin: 0 0 0.6rem;
line-height: 1.6;
color: #374151;
font-size: 0.95rem;
}
.card ul {
margin: 0 0 0.6rem;
padding-left: 1.25rem;
line-height: 1.6;
color: #374151;
font-size: 0.95rem;
}
.card li {
margin-bottom: 0.35rem;
}
.card a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.card a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.card {
padding: 1.5rem;
}
}
</style>
@@ -6,6 +6,7 @@ import { setSessionCookie } from '$lib/server/session';
import { issueAccess } from '$lib/server/member-otp'; import { issueAccess } from '$lib/server/member-otp';
import { slugify, handle, famUsername, handleOf } from '@shared/slugify'; import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; import { applyAccessCode } from '$lib/server/access';
import { sendWelcomeEmail, sendAccessUnlockedEmail } from '$lib/server/email';
import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe'; import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe';
import type { PlanId } from '$lib/server/stripe'; import type { PlanId } from '$lib/server/stripe';
@@ -70,6 +71,10 @@ export const actions = {
.catch(() => null); .catch(() => null);
if (authResult?.token) setSessionCookie(event.cookies, authResult.token); if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
// Welcome email — a best-effort send. Failures must not block signup.
const dashboardUrl = `${event.url.origin}/${slug}`;
sendWelcomeEmail({ to: email, famName, dashboardUrl }).catch(() => {});
return { success: true, famSlug: slug, username: handleName }; return { success: true, famSlug: slug, username: handleName };
}, },
@@ -111,6 +116,27 @@ export const actions = {
const result = await applyAccessCode(user.famId, code); const result = await applyAccessCode(user.famId, code);
if (result.error) return fail(400, { error: result.error }); if (result.error) return fail(400, { error: result.error });
// Access code applied successfully — confirm by email (best-effort).
// Failure must never block the user continuing to the plan step.
if (result.ok) {
const fam = await pbAdmin
.getOne('fams', user.famId)
.catch(() => ({ name: '', slug: user.famId }));
const parents = await pbAdmin
.getList('users', `famId = '${user.famId}' && role = 'parent'`)
.catch(() => []);
const parentEmail = (parents[0] as { email?: string } | undefined | null)?.email;
if (parentEmail) {
sendAccessUnlockedEmail({
to: parentEmail,
famName: fam.name || '',
codeName: result.code?.name || code,
codeValue: code,
dashboardUrl: `${event.url.origin}/${fam.slug || user.famId}`
}).catch(() => {});
}
}
return { ok: true, ...result }; return { ok: true, ...result };
}, },
+19
View File
@@ -189,6 +189,10 @@
/> />
</label> </label>
<button type="submit" disabled={submitting}>Create my family</button> <button type="submit" disabled={submitting}>Create my family</button>
<p class="privacy-note">
By signing up you agree to our <a href="/privacy">Privacy Policy</a>. We collect your email
only to create and log into your account, never sell or share your data.
</p>
</form> </form>
<p class="alt">Already have a family? <a href="/login">Log in</a></p> <p class="alt">Already have a family? <a href="/login">Log in</a></p>
{:else if step === 'child'} {:else if step === 'child'}
@@ -381,6 +385,21 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #059669; color: #059669;
} }
.privacy-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #6b7280;
line-height: 1.5;
text-align: center;
}
.privacy-note a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.privacy-note a:hover {
text-decoration: underline;
}
.actions { .actions {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.9.2" "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".
+211
View File
@@ -0,0 +1,211 @@
// Bonus progress computation — single source of truth shared by the server
// rewards loader (`services/bonuses.ts` → `progress()`) and the Rewards page
// client-side realtime recompute (famStore + PB SSE). Both views must show the
// same counts, so the algorithm lives here rather than in two places.
import { bonusWindow, completionInWindow } from "./timezone";
export interface BonusProgressConfig {
id: string;
target: string;
memberId?: string;
type: string;
thresholdType?: string;
criteriaValue?: number;
period?: string;
targetChoreId?: string;
targetChoreIds?: Record<string, string> | null;
}
export interface BonusProgressMember {
id: string;
name: string;
color: string;
}
export interface BonusProgressAssigned {
id: string;
memberId?: string | null;
frequency?: string;
type?: string;
value?: number | string;
}
export interface BonusProgressCompletion {
memberId: string;
assignedChoreId: string;
date?: string;
}
export interface BonusProgressReward {
id: string;
memberId: string;
bonusConfigId?: string;
status: string;
}
export interface BonusProgressRow {
memberId: string;
memberName: string;
memberColor: string;
current: number;
criteriaValue: number;
reward: { id: string; status: string } | null;
state: string;
achieved: boolean;
}
export interface BonusConfigWithProgress {
config: BonusProgressConfig;
progress: BonusProgressRow[];
periodStart: string;
periodEnd: string;
}
// Per-member target-chore pick wins over the legacy scalar targetChoreId.
function targetChoreFor(
cfg: BonusProgressConfig,
memberId: string,
): string | undefined {
if (
cfg.targetChoreIds &&
typeof cfg.targetChoreIds === "object" &&
cfg.targetChoreIds[memberId]
) {
return cfg.targetChoreIds[memberId];
}
return cfg.targetChoreId || undefined;
}
// Compute per-config progress rows for every member (or the team/winner for
// collaborative/competitive targets). Callers pass the configs they want
// scored (the server uses active-only; the Rewards page mirrors that).
export function computeBonusProgress(
configs: BonusProgressConfig[],
members: BonusProgressMember[],
assigned: BonusProgressAssigned[],
completions: BonusProgressCompletion[],
rewards: BonusProgressReward[],
payday: number,
tz: string,
): BonusConfigWithProgress[] {
// Chores "due" per week per member (daily = 7, otherwise 1). Used by the
// `percent` threshold type to compute % of chores completed.
const dueByMember: Record<string, number> = {};
for (const a of assigned) {
if (a.memberId) {
dueByMember[a.memberId] =
(dueByMember[a.memberId] || 0) + (a.frequency === "daily" ? 7 : 1);
}
}
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
const result: BonusConfigWithProgress[] = [];
for (const cfg of configs) {
const cfgRewards = rewards.filter((r) => r.bonusConfigId === cfg.id);
const win = bonusWindow(cfg, payday, tz);
const pStart = win.from;
const pEnd = win.to;
const periodCompletions = completions.filter((c) =>
completionInWindow(c, win),
);
const progressRows: BonusProgressRow[] = [];
if (cfg.target === "collaborative") {
let teamCompletions = periodCompletions.filter((c) =>
members.some((m) => m.id === c.memberId),
);
if (cfg.targetChoreId) {
teamCompletions = teamCompletions.filter(
(c) => c.assignedChoreId === cfg.targetChoreId,
);
}
let teamCurrent = 0;
if (cfg.type === "threshold") {
if (cfg.thresholdType === "percent") {
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
} else {
teamCurrent = teamCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === "count") {
teamCurrent = teamCompletions.length;
}
const teamReward = cfgRewards[0];
progressRows.push({
memberId: "__team__",
memberName: "Team Total",
memberColor: "#8b5cf6",
current: teamCurrent,
criteriaValue: cfg.criteriaValue || 0,
reward: teamReward
? { id: teamReward.id, status: teamReward.status }
: null,
state: teamReward ? teamReward.status : "pending",
achieved: teamReward ? true : false,
});
}
if (cfg.target !== "collaborative") {
const progressMembers =
cfg.target === "individual" && cfg.memberId
? members.filter((m) => m.id === cfg.memberId)
: members;
for (const m of progressMembers) {
const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter(
(c) => c.memberId === m.id,
);
if (memberTarget) {
memberCompletions = memberCompletions.filter(
(c) => c.assignedChoreId === memberTarget,
);
}
const memberReward = cfgRewards.find((r) => r.memberId === m.id);
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, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === "count") {
current = memberCompletions.length;
} else if (cfg.type === "manual") {
current = 0;
}
progressRows.push({
memberId: m.id,
memberName: m.name,
memberColor: m.color,
current,
criteriaValue: cfg.criteriaValue || 0,
reward: memberReward
? { id: memberReward.id, status: memberReward.status }
: null,
state: memberReward ? memberReward.status : "pending",
achieved: memberReward ? true : false,
});
}
}
result.push({
config: cfg,
progress: progressRows,
periodStart: pStart,
periodEnd: pEnd,
});
}
return result;
}
+98 -33
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,17 +159,27 @@ 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(
"bonus_templates",
[
rel("famId", ids.fams, false), rel("famId", ids.fams, false),
text("name", true), text("name", true),
text("description"), text("description"),
select("target", ["individual", "competitive", "collaborative"], true), select(
"target",
["individual", "competitive", "collaborative"],
true,
),
select("type", ["threshold", "count", "manual"], true), select("type", ["threshold", "count", "manual"], true),
select("thresholdType", ["points", "percent"], false), select("thresholdType", ["points", "percent"], false),
select("occurrence", ["recurring", "once"], true), select("occurrence", ["recurring", "once"], true),
@@ -173,27 +191,35 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
bool("global"), bool("global"),
text("icon"), text("icon"),
text("color"), text("color"),
], { ],
{
listRule: "global = true || famId = @request.auth.famId", listRule: "global = true || famId = @request.auth.famId",
viewRule: "global = true || famId = @request.auth.famId", viewRule: "global = true || famId = @request.auth.famId",
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
})(ids), },
)(ids),
}, },
{ {
name: "settings", name: "settings",
build: (ids) => build: (ids) =>
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], { col(
"settings",
[rel("famId", ids.fams, true), text("webhookUrl"), number("lockMins")],
{
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
})(ids), },
)(ids),
}, },
{ {
name: "chore_templates", name: "chore_templates",
build: (ids) => build: (ids) =>
col("chore_templates", [ col(
"chore_templates",
[
rel("famId", ids.fams, false), rel("famId", ids.fams, false),
text("name", true), text("name", true),
text("description"), text("description"),
@@ -203,13 +229,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
bool("global"), bool("global"),
text("icon"), text("icon"),
text("color"), text("color"),
], { ],
{
listRule: "global = true || famId = @request.auth.famId", listRule: "global = true || famId = @request.auth.famId",
viewRule: "global = true || famId = @request.auth.famId", viewRule: "global = true || famId = @request.auth.famId",
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
})(ids), },
)(ids),
}, },
{ {
name: "bonus_configs", name: "bonus_configs",
@@ -228,7 +256,10 @@ 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"),
bool("isPocketMoney"), bool("isPocketMoney"),
// Standalone/one-off reward progress window. `period` stays empty for // Standalone/one-off reward progress window. `period` stays empty for
// these (they're measured once, not per-cycle). `completeBy` bounds how // these (they're measured once, not per-cycle). `completeBy` bounds how
@@ -248,7 +279,9 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
{ {
name: "weekly_history", name: "weekly_history",
build: (ids) => build: (ids) =>
col("weekly_history", [ col(
"weekly_history",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.users, true), rel("memberId", ids.users, true),
date("weekStart"), date("weekStart"),
@@ -256,32 +289,40 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
number("moneyEarned"), number("moneyEarned"),
number("choresCompleted"), number("choresCompleted"),
number("bonusEarned"), number("bonusEarned"),
], { ],
{
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
})(ids), },
)(ids),
}, },
{ {
name: "seasons", name: "seasons",
build: (ids) => build: (ids) =>
col("seasons", [ col(
"seasons",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
text("name", true), text("name", true),
text("color"), text("color"),
bool("active"), bool("active"),
date("autoDisable"), date("autoDisable"),
date("autoStart"), date("autoStart"),
], { ],
{
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
})(ids), },
)(ids),
}, },
{ {
name: "messages", name: "messages",
build: (ids) => build: (ids) =>
col("messages", [ col(
"messages",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
select("authorType", ["admin", "member"], true), select("authorType", ["admin", "member"], true),
text("authorId", true), text("authorId", true),
@@ -292,36 +333,44 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
// API-created collections (0.25 did). Chat filters/sorts on it. // API-created collections (0.25 did). Chat filters/sorts on it.
date("createdAt"), date("createdAt"),
text("clientId"), text("clientId"),
], { ],
{
createRule: RULE_FAM_WRITE, createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED, updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ, listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ, viewRule: RULE_FAM_READ,
})(ids), },
)(ids),
}, },
{ {
name: "chat_typing", name: "chat_typing",
build: (ids) => build: (ids) =>
col("chat_typing", [ col(
"chat_typing",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
text("actorId", true), text("actorId", true),
select("actorType", ["admin", "member"], true), select("actorType", ["admin", "member"], true),
text("authorName", true), text("authorName", true),
text("authorColor"), text("authorColor"),
bool("typing"), bool("typing"),
], { ],
{
createRule: RULE_FAM_WRITE, createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED, updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ, listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ, viewRule: RULE_FAM_READ,
})(ids), },
)(ids),
}, },
{ {
name: "rewards", name: "rewards",
build: (ids) => build: (ids) =>
col("rewards", [ col(
"rewards",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.users, true), rel("memberId", ids.users, true),
rel("bonusConfigId", ids.bonus_configs), rel("bonusConfigId", ids.bonus_configs),
@@ -334,16 +383,22 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
date("claimedAt"), date("claimedAt"),
date("requestedAt"), date("requestedAt"),
text("date"), text("date"),
], { ],
{
createRule: RULE_FAM_WRITE, createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED, updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED,
})(ids), listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
{ {
name: "assigned_chores", name: "assigned_chores",
build: (ids) => build: (ids) =>
col("assigned_chores", [ col(
"assigned_chores",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.users, false), // null = shared with all members rel("memberId", ids.users, false), // null = shared with all members
bool("shared"), // true = initially shared (tracked for revoke) bool("shared"), // true = initially shared (tracked for revoke)
@@ -360,26 +415,36 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
bool("isTodo"), bool("isTodo"),
text("startDate"), text("startDate"),
text("completeBy"), text("completeBy"),
], { ],
{
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
})(ids), listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
},
)(ids),
}, },
{ {
name: "completions", name: "completions",
build: (ids) => build: (ids) =>
col("completions", [ col(
"completions",
[
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.users, true), rel("memberId", ids.users, true),
rel("assignedChoreId", ids.assigned_chores, true), rel("assignedChoreId", ids.assigned_chores, true),
date("date"), date("date"),
date("completedAt"), date("completedAt"),
text("rewardId"), text("rewardId"),
], { ],
{
createRule: RULE_FAM_WRITE, createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED, updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED,
})(ids), 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);
} }