update ui and other frontend updates

This commit is contained in:
JCEEE
2026-07-29 09:41:52 +01:00
parent 04b83925e0
commit 57ef30d3a7
79 changed files with 7323 additions and 429 deletions
+1
View File
@@ -8,6 +8,7 @@ node_modules
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
+87 -6
View File
@@ -18,11 +18,15 @@
## Auth
| Role | Auth | Session |
| ---------------- | -------------------------- | --------------------------- |
| Super admin (me) | PB email+pass | 24hr JWT |
| Fam admin | PB email+pass | 24hr JWT, scoped to own fam |
| Member | Invite code + device token | localStorage, no expiry |
| Role | Auth | Session | Record in |
| ---------------- | -------------------------- | --------------------------- | -------------------- |
| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` |
| Member (child) | Invite code + device token | `device_token` cookie only | `members` |
- **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`.
- **Members** (children) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie.
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard). Not an app role.
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session cookie — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
## PB Collections (all scoped by `famId`)
@@ -42,9 +46,15 @@
/ Landing (SaaS marketing)
/admin Admin panel - statistic dashboard, and any donations made
/join/:code Member invite code
/join/:code/:member Member invite with pre-selected member
/{fam} Fam dashboard
/{fam}/admin Admin panel
/{fam}/:username Member kanban (?token= for auth)
/{fam}/:username Member kanban & admin dashboard (role determined by session)
/{fam}/:username/preferences User preferences (admin→fam_admins, member→members)
/{fam}/:username/settings Family admin settings (session required)
/{fam}/:username/chores Chore templates & assignment grid
/{fam}/:username/rewards Rewards overview
/{fam}/:username/bonuses Bonus configs & evaluation
/api/* Hono proxy (webhooks, CRON)
```
@@ -58,11 +68,82 @@
- **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface
- **UI reactivity:** Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE
## Env Vars (SvelteKit 3.0.0-next.4)
Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`:
```ts
import { defineEnvVars } from '@sveltejs/kit/hooks';
export const variables = defineEnvVars({
DEBUG_RECORD_ID: {},
SERVER_IP: {public: true},
PB_PORT: {public: true}
});
```
Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must:
1. Add it to `.env` with `PUBLIC_` prefix
2. Add it to `frontend/src/env.ts` with `{public: true}`
The actual values come from `.env` (symlinked from project root at `frontend/.env -> ../.env`). Server-only env vars (no `{public}` flag) are available via `$app/env/private` but only in server modules.
## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore)
**Do NOT do this:**
```svelte
<!-- ❌ WRONG: $effect syncing local state from famStore fights Svelte reactivity -->
let items = $state(data.items);
$effect(() => {
if (data.items?.length) items = data.items;
if (famStore.initialized && famStore.items.length) items = famStore.items;
});
```
**Do this instead:**
```svelte
<!-- ✅ RIGHT: Initialize from famStore, mutate via applyRecord in callbacks -->
let items = $state(famStore.initialized ? famStore.items : (data.items || []));
```
**Form action callbacks must call `famStore.applyRecord()`:**
```ts
// In use:enhance callback:
famStore.applyRecord('collection_name', record, 'create' | 'update' | 'delete');
```
The `famStore` has reactive `$state` properties and an `applyRecord()` method designed for instant UI feedback from form actions. Calling `applyRecord()` mutates the store directly — the UI updates immediately without waiting for PB SSE. The SSE subscription is a backup for multi-user sync only. Do NOT add `$effect` watchers to bridge the gap between form actions and reactive state.
## UI Component Conventions
All admin and member pages use the following pattern:
```svelte
<ViewHeader title="..." subtitle="..." tabs={...} weeknav={...} sort={...} />
<CardGrid>
<Card {cols} title="..." accent="...">
<!-- card content → micro-layout per page -->
</Card>
</CardGrid>
```
- **Layout shell** lives in `[fam]/+layout.svelte` — Sidebar, TopNav, Footer. All fam-scoped routes inherit it.
- **Sidebar** role-aware: shows admin CTAs on `/admin/*`, member CTAs on `/[username]/*`.
- **TopNav** has `announcement` (center slot) and `actions` (right slot).
- **`Card` `cols` prop**: `1 | 2 | 3` — spans that many columns in the 3-column `CardGrid`.
- **`Button`** for all CTAs: `<Button variant="primary|secondary|ghost|danger" size="sm|md|lg">`.
- **`Accordion`** for expand/collapse sections (settings, logs).
- **Icons**: defined as SVG strings in `lib/components/icons.ts`. No icon library dependency.
- **Components** live in `frontend/src/lib/components/` and are re-exported from `index.ts`.
## Conventions
- Every collection query includes `famId = @request.auth.famId` filter
- Super admin bypasses famId filter (access via PB admin API)
- `deviceToken` stored as SHA-256 hash; never log raw tokens
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars
- `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template
- Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase)
+77 -1
View File
@@ -1,6 +1,76 @@
# FamChore v2 — Development Memory
## Architecture Decisions
## UI Component Architecture (Jul 2026)
### Layout Hierarchy
```
+layout.svelte ← global styles, meta, favicon
├── /login, /signup, /join/* ← auth pages (no shell)
└── [fam]/+layout.svelte ← Shell: Sidebar + TopNav + Footer + claim toast
├── [fam]/+page.svelte ← fam dashboard
├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban
├── [fam]/{username}/chores/+page.svelte ← parent only
├── [fam]/{username}/rewards/+page.svelte ← parent only
├── [fam]/{username}/bonuses/+page.svelte ← parent only
├── [fam]/{username}/settings/+page.svelte ← parent only
└── [fam]/{username}/preferences/+page.svelte ← both roles
```
### Sidebar (collapsible to mini-mode)
- Header: app name (FamChore)
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
- Member CTAs: Dashboard, Preferences
- Footer: family name, Settings (admin only), Log out
- Role-aware: items differ based on admin vs member route
### TopNav
- Slot `announcement` (center) — system/family messages
- Slot `actions` (right) — user status, claim/message
### Page Content
- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort)
- `CardGrid` — 3-column grid, Cards span columns via `cols` prop
- `Card` — 1/2/3 col span, micro-layout per page
- `Accordion` — for settings / log sections
- `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg)
### Components (frontend/src/lib/components/)
- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte`
- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte`
- `Button.svelte`, `Accordion.svelte`
- `icons.ts` — SVG icon strings (no icon library dep)
### Role-Based Auth (Jul 2026)
- Members have `role` field (`'parent' | 'child'`, added to `members` collection)
- Parents authenticate via email/password (PB session JWT), children via device token
- `/api/admin/signup` now creates a member record for the parent with `role: 'parent'`
- `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info
- `/api/members/verify-token` returns `role` for the frontend
- `requireAdmin` middleware unchanged (still checks `fam_admins`)
- Frontend sidebar is role-aware: `isParent = session !== null`
- **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc.
### Routes (Jul 2026)
```
/ Landing (SaaS marketing)
/signup Signup (creates parent user + member)
/login Login (returns member info, redirects to /{fam}/{memberName})
/join/:code Member invite (child)
/join/:code/:member Member invite with pre-selected name
/admin Super admin dashboard (unchanged)
/{fam} Fam dashboard
/{fam}/{username} Parent → admin overview, Child → kanban
/{fam}/{username}/chores Parent: chore management
/{fam}/{username}/rewards Parent: reward ledger
/{fam}/{username}/bonuses Parent: bonus configs
/{fam}/{username}/settings Parent: family settings
/{fam}/{username}/preferences Both: edit name/color
/api/* Hono proxy
```
### Architecture Decisions
### 2026-06-23 — Monorepo & Docker Setup
@@ -16,3 +86,9 @@
- **All data operations** (reads and writes) go through the Hono proxy, never directly to PB SDK.
- **UI reactivity** is purely Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE.
- The `/debug` page's PB SDK `subscribe()` was experimental only; final apps fetch via Hono proxy and update Svelte state reactively.
### 2026-07-28 — Auth Bug: Join Flow Set Session Cookie for Children
- **Bug**: Both `join/[code]/+page.server.ts` and `join/[code]/[member]/+page.server.ts` called `setSessionCookie()` with `userId: memberId` (the child's PB record ID). This made `event.locals.session` truthy for children, causing `[username]/+page.server.ts` to enter the admin branch and call `hono.admin.*` endpoints. The proxy's `requireAdmin` checked `fam_admins` for the child's member ID (which doesn't exist) and returned 401.
- **Fix**: Removed `setSessionCookie()` from both join pages. Children only get a `device_token` cookie. The session cookie is only for email/password-authenticated parents, set by `/login` and `/signup`.
- **Lesson**: Children must never get a session cookie. The auth table in AGENTS.md says "Member → device token, no expiry" — the code must match.
-3
View File
@@ -1,3 +0,0 @@
// this file is generated — do not edit it
/// <reference types="@sveltejs/kit" />
-11
View File
@@ -1,11 +0,0 @@
// This file is generated from ../src/env.ts.
// See https://svelte.dev/docs/kit/environment-variables for more information
declare module '$app/env/private' {
export const DEBUG_RECORD_ID: string;
}
declare module '$app/env/public' {
export const SERVER_IP: string;
export const PB_PORT: string;
}
@@ -1,31 +0,0 @@
export { matchers } from './matchers.js';
export const nodes = [
() => import('./nodes/0'),
() => import('./nodes/1'),
() => import('./nodes/2'),
() => import('./nodes/3')
];
export const server_loads = [];
export const dictionary = {
"/": [2],
"/debug": [~3]
};
export const hooks = {
handleError: (({ error }) => { console.error(error) }),
reroute: (() => {}),
transport: {}
};
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
export const hash = false;
export const decode = (type, value) => decoders[type](value);
export { default as root } from '../root.js';
@@ -1 +0,0 @@
export const matchers = {};
@@ -1 +0,0 @@
export { default as component } from "../../../../src/routes/+layout.svelte";
@@ -1 +0,0 @@
export { default as component } from "../../../../../node_modules/.pnpm/@sveltejs+kit@3.0.0-next.4_@sveltejs+vite-plugin-svelte@7.1.2_svelte@5.56.3_vite@8.0.16_831df308509456bf75377b19fdb5b8d4/node_modules/@sveltejs/kit/src/runtime/components/error.svelte";
@@ -1 +0,0 @@
export { default as component } from "../../../../src/routes/+page.svelte";
@@ -1 +0,0 @@
export { default as component } from "../../../../src/routes/debug/+page.svelte";
-3
View File
@@ -1,3 +0,0 @@
import { asClassComponent } from 'svelte/legacy';
import Root from './root.svelte';
export default asClassComponent(Root);
@@ -1,75 +0,0 @@
<!-- This file is generated by @sveltejs/kit — do not edit it! -->
<svelte:options runes={true} />
<script>
import { setContext, onMount, tick } from 'svelte';
import { browser } from '$app/env';
// stores
let { stores, page, constructors, components = [], form, errors = [], error, data_0 = null, data_1 = null } = $props();
let data = $derived({'0': data_0, '1': data_1})
if (browser) {
$effect.pre(() => stores.page.set(page));
} else {
// svelte-ignore state_referenced_locally
setContext('__svelte__', stores);
// svelte-ignore state_referenced_locally
stores.page.set(page);
}
$effect(() => {
stores;page;constructors;components;form;errors;error;data_0;data_1;
stores.page.notify();
});
let mounted = $state(false);
let navigated = $state(false);
let title = $state(null);
onMount(() => {
const unsubscribe = stores.page.subscribe(() => {
if (mounted) {
navigated = true;
tick().then(() => {
title = document.title || 'untitled page';
});
}
});
mounted = true;
return unsubscribe;
});
const Pyramid_1 = $derived(constructors[1]);
</script>
{#snippet pyramid(depth)}
{@const Pyramid = constructors[depth]}
{#snippet failed(error)}
{@const ErrorPage = errors[depth]}
<ErrorPage {error} />
{/snippet}
<svelte:boundary failed={errors[depth] ? failed : undefined}>
{#if constructors[depth + 1]}
{@const d = data[depth]}
<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid bind:this={components[depth]} data={d} {form} params={page.params}>
{@render pyramid(depth + 1)}
</Pyramid>
{:else}
{@const d = data[depth]}
<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid bind:this={components[depth]} data={d} {form} params={page.params} {error} />
{/if}
</svelte:boundary>
{/snippet}
{@render pyramid(0)}
{#if mounted}
<div id="svelte-announcer" aria-live="assertive" aria-atomic="true" style="position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px">
{#if navigated}
{title}
{/if}
</div>
{/if}
@@ -1,52 +0,0 @@
import root from '../root.js';
import { set_building, set_prerendering } from '$app/env/internal';
import { set_assets } from '$app/paths/internal/server';
import { set_manifest, set_read_implementation } from '__sveltekit/server';
import { set_env } from '__sveltekit/env';
export const options = {
app_template_contains_nonce: false,
async: true,
csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}},
csrf_check_origin: false,
csrf_trusted_origins: ["*"],
embedded: false,
hash_routing: false,
hooks: null, // added lazily, via `get_hooks`
link_header_preload: false,
root,
service_worker: false,
service_worker_options: undefined,
server_error_boundaries: true,
templates: {
app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t<meta name=\"text-scale\" content=\"scale\" />\n\t\t" + head + "\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">" + body + "</div>\n\t</body>\n</html>\n",
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
},
version_hash: "p7lyxi"
};
export async function get_hooks() {
let handle;
let handleFetch;
let handleError;
let handleValidationError;
let init;
let reroute;
let transport;
return {
handle,
handleFetch,
handleError,
handleValidationError,
init,
reroute,
transport
};
}
export { set_assets, set_building, set_env, set_manifest, set_prerendering, set_read_implementation };
-44
View File
@@ -1,44 +0,0 @@
// this file is generated — do not edit it
declare module "svelte/elements" {
export interface HTMLAttributes<T> {
'data-sveltekit-keepfocus'?: true | false | '' | undefined | null;
'data-sveltekit-noscroll'?: true | false | '' | undefined | null;
'data-sveltekit-preload-code'?:
| true
| false
| ''
| 'eager'
| 'viewport'
| 'hover'
| 'tap'
| undefined
| null;
'data-sveltekit-preload-data'?: true | false | '' | 'hover' | 'tap' | undefined | null;
'data-sveltekit-reload'?: true | false | '' | undefined | null;
'data-sveltekit-replacestate'?: true | false | '' | undefined | null;
}
}
export {};
declare module "$app/types" {
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
export interface AppTypes {
RouteId(): "/" | "/debug";
RouteParams(): {
};
LayoutParams(): {
"/": Record<string, never>;
"/debug": Record<string, never>
};
Pathname(): "/" | "/debug";
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
Asset(): "/robots.txt" | string & {};
}
}
-60
View File
@@ -1,60 +0,0 @@
{
"compilerOptions": {
"paths": {
"$lib": [
"../src/lib"
],
"$lib/*": [
"../src/lib/*"
],
"$app/types": [
"./types/index.d.ts"
]
},
"rootDirs": [
"..",
"./types"
],
"verbatimModuleSyntax": true,
"isolatedModules": true,
"lib": [
"esnext",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
"target": "esnext",
"types": [
"node"
]
},
"include": [
"ambient.d.ts",
"env.d.ts",
"non-ambient.d.ts",
"./types/**/$types.d.ts",
"../svelte.config.js",
"../vite.config.js",
"../vite.config.ts",
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.svelte",
"../test/**/*.js",
"../test/**/*.ts",
"../test/**/*.svelte",
"../tests/**/*.js",
"../tests/**/*.ts",
"../tests/**/*.svelte"
],
"exclude": [
"../node_modules/**",
"../src/service-worker.js",
"../src/service-worker/**/*.js",
"../src/service-worker.ts",
"../src/service-worker/**/*.ts",
"../src/service-worker.d.ts",
"../src/service-worker/**/*.d.ts"
]
}
@@ -1,6 +0,0 @@
{
"/": [],
"/debug": [
"src/routes/debug/+page.server.ts"
]
}
-23
View File
@@ -1,23 +0,0 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
type RouteParams = { };
type RouteId = '/';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageParentData = EnsureDefined<LayoutData>;
type LayoutRouteId = RouteId | "/" | "/debug" | null
type LayoutParams = RouteParams & { }
type LayoutParentData = EnsureDefined<{}>;
export type PageServerData = null;
export type PageData = Expand<PageParentData>;
export type PageProps = { params: RouteParams; data: PageData }
export type LayoutServerData = null;
export type LayoutData = Expand<LayoutParentData>;
export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet }
-24
View File
@@ -1,24 +0,0 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
type RouteParams = { };
type RouteId = '/debug';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
export type ActionData = unknown;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../src/routes/debug/+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
+2 -1
View File
@@ -3,5 +3,6 @@ import { defineEnvVars } from '@sveltejs/kit/hooks';
export const variables = defineEnvVars({
DEBUG_RECORD_ID: {},
SERVER_IP: {public: true},
PB_PORT: {public: true}
PB_PORT: {public: true},
PUBLIC_PB_URL: {public: true}
});
+16
View File
@@ -0,0 +1,16 @@
import type { Handle } from '@sveltejs/kit';
const COOKIE_NAME = 'session';
export const handle: Handle = async ({ event, resolve }) => {
const raw = event.cookies.get(COOKIE_NAME);
if (raw) {
try {
event.locals.session = JSON.parse(raw);
} catch {
event.cookies.delete(COOKIE_NAME, { path: '/' });
}
}
return resolve(event);
};
+39
View File
@@ -0,0 +1,39 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const BASE_URL = typeof window === 'undefined'
? `http://${SERVER_IP}:${PROXY_PORT}`
: '';
async function memberFetch<T = unknown>(
method: string,
path: string,
token: string,
famId: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
'x-device-token': token,
'x-device-famid': famId,
};
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
return data as T;
}
export const memberApi = {
async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) {
return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date });
},
async myChores(token: string, famId: string) {
return memberFetch('POST', '/api/members/my-chores', token, famId);
},
async claimReward(token: string, famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
},
};
@@ -0,0 +1,36 @@
<script lang="ts">
let { items }: { items: { title: string; content: any }[] } = $props();
let openIndex = $state<number | null>(null);
</script>
<div class="accordion">
{#each items as item, i}
<div class="accordion-item" class:open={openIndex === i}>
<button class="accordion-trigger" onclick={() => openIndex = openIndex === i ? null : i}>
<span>{item.title}</span>
<span class="accordion-arrow">{openIndex === i ? '▾' : '▸'}</span>
</button>
{#if openIndex === i}
<div class="accordion-body">
{@render item.content()}
</div>
{/if}
</div>
{/each}
</div>
<style>
.accordion { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.9rem; font-weight: 500; color: #374151;
cursor: pointer; text-align: left;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
href?: string;
onclick?: () => void;
children?: any;
[key: string]: unknown;
} = $props();
</script>
{#if href}
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}>
{@render children?.()}
</a>
{:else}
<button class="btn btn-{variant} btn-{size}" {onclick} {...rest}>
{@render children?.()}
</button>
{/if}
<style>
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
border: 1px solid transparent;
}
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
.btn-md { padding: 0.45rem 0.9rem; font-size: 0.85rem; }
.btn-lg { padding: 0.6rem 1.2rem; font-size: 0.95rem; }
.btn-primary { background: #4338ca; color: #fff; border-color: #4338ca; }
.btn-primary:hover { background: #3730a3; }
.btn-secondary { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
.btn-secondary:hover { background: #e5e7eb; }
.btn-ghost { background: transparent; color: #6b7280; border-color: transparent; }
.btn-ghost:hover { background: #f3f4f6; }
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.btn-danger:hover { background: #b91c1c; }
</style>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
let { cols = 1, title, accent, children }: { cols?: 1 | 2 | 3; title?: string; accent?: string; children?: any } = $props();
</script>
<div class="card" style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}" class:has-accent={!!accent}>
{#if title}
<div class="card-header">
<span class="card-title">{title}</span>
</div>
{/if}
<div class="card-body">
{@render children?.()}
</div>
</div>
<style>
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
}
.card.has-accent { border-top: 3px solid var(--card-accent, #6366f1); }
.card-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
background: #fafafa;
}
.card-title { font-weight: 600; font-size: 0.9rem; color: #374151; }
.card-body { padding: 1rem; }
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
let { children }: { children?: any } = $props();
</script>
<div class="card-grid">
{@render children?.()}
</div>
<style>
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 900px) {
.card-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.card-grid { grid-template-columns: 1fr; }
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<footer class="app-footer">
<span class="footer-text">FamChore</span>
</footer>
<style>
.app-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 0.8rem;
color: #9ca3af;
}
</style>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { page } from '$app/state';
import { dashboardIcon, choresIcon, rewardsIcon, bonusesIcon, homeIcon, settingsIcon, logoutIcon, prefsIcon } from './icons';
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
let collapsed = $state(false);
let famSlug = $derived(page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || '');
function toggle() { collapsed = !collapsed; }
let showChildItems = $derived(!!memberName);
let navItems = $derived(isParent && memberName
? [
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
]
: showChildItems
? [
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
]
: []
);
let footerItems = $derived([
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
]);
</script>
<aside class="sidebar" class:collapsed>
<button class="toggle-btn" onclick={toggle}>
{collapsed ? '☰' : '✕'}
</button>
<div class="sidebar-header">
<span class="app-icon"></span>
{#if !collapsed}<span class="app-name">FamChore</span>{/if}
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
{@html item.icon}
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#each footerItems as item}
<a href={item.href} class="nav-item">
{@html item.icon}
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
</a>
{/each}
</div>
</aside>
<style>
.sidebar {
position: fixed;
top: 0; left: 0;
height: 100vh;
width: 220px;
background: #1e1b4b;
color: #e0e7ff;
display: flex;
flex-direction: column;
transition: width 0.2s;
z-index: 100;
overflow: hidden;
}
.sidebar.collapsed { width: 56px; }
.toggle-btn {
position: absolute;
top: 0.5rem; right: 0.5rem;
background: none;
border: none;
color: #a5b4fc;
font-size: 1.1rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.75rem;
border-bottom: 1px solid #3730a3;
min-height: 52px;
}
.app-icon { font-size: 1.3rem; flex-shrink: 0; }
.app-name { font-weight: 700; font-size: 1.05rem; white-space: nowrap; }
.sidebar-nav {
flex: 1;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-footer {
border-top: 1px solid #3730a3;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
color: #c7d2fe;
text-decoration: none;
font-size: 0.9rem;
border-radius: 6px;
margin: 0 0.3rem;
white-space: nowrap;
transition: background 0.15s;
}
.nav-item:hover { background: #3730a3; color: #e0e7ff; }
.nav-item.active { background: #4338ca; color: #fff; font-weight: 600; }
.nav-label { overflow: hidden; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
let { announcement = '', children }: { announcement?: string; children?: any } = $props();
</script>
<header class="topnav">
<div class="topnav-announcement">
{#if announcement}<span class="announcement-text">{announcement}</span>{/if}
</div>
<div class="topnav-actions">
{@render children?.()}
</div>
</header>
<style>
.topnav {
position: fixed;
top: 0; left: 220px; right: 0;
height: 48px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
padding: 0 1.5rem;
z-index: 90;
transition: left 0.2s;
}
.topnav-announcement { flex: 1; text-align: center; }
.announcement-text { font-size: 0.85rem; color: #6b7280; }
.topnav-actions { display: flex; align-items: center; gap: 0.5rem; }
</style>
@@ -0,0 +1,71 @@
<script lang="ts">
let { title, subtitle, tabs, weeknav, sort }: {
title: string;
subtitle?: string;
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
} = $props();
</script>
<div class="view-header">
<div class="view-title-group">
<h1 class="view-title">{title}</h1>
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
</div>
<div class="view-tools">
{#if tabs}
<div class="tab-bar">
{#each tabs.items as tab}
<button
class="tab-btn"
class:active={tab.value === tabs.active}
onclick={() => tabs.onchange(tab.value)}
>{tab.label}</button>
{/each}
</div>
{/if}
{#if weeknav}
<div class="week-nav">
<button class="nav-btn" onclick={weeknav.onPrev}></button>
<span class="nav-label">{weeknav.current}</span>
<button class="nav-btn" onclick={weeknav.onNext}></button>
</div>
{/if}
{#if sort}
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}>
{#each sort.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
</div>
<style>
.view-header { margin-bottom: 1.5rem; }
.view-title-group { margin-bottom: 0.5rem; }
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; }
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; }
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; }
.tab-btn {
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px;
font-size: 0.85rem; color: #6b7280; cursor: pointer;
}
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
.nav-btn {
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px;
cursor: pointer; font-size: 1rem; line-height: 1;
}
.nav-btn:hover { background: #f3f4f6; }
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
.sort-select {
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px;
font-size: 0.85rem; background: #fff;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
export const bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
export const 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 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>'
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>'
+8
View File
@@ -0,0 +1,8 @@
export { default as Sidebar } from './Sidebar.svelte';
export { default as TopNav } from './TopNav.svelte';
export { default as Footer } from './Footer.svelte';
export { default as ViewHeader } from './ViewHeader.svelte';
export { default as Card } from './Card.svelte';
export { default as CardGrid } from './CardGrid.svelte';
export { default as Button } from './Button.svelte';
export { default as Accordion } from './Accordion.svelte';
+9
View File
@@ -0,0 +1,9 @@
export async function sha256(input: string): Promise<string> {
const hash = await globalThis.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(input),
);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
+12 -3
View File
@@ -1,5 +1,14 @@
import PocketBase from 'pocketbase';
import { SERVER_IP, PB_PORT } from '$app/env/public';
import { PUBLIC_PB_URL } from '$app/env/public';
const url = `http://${SERVER_IP}:${PB_PORT}`;
export const pb: PocketBase = new PocketBase(url);
export const pb = new PocketBase(PUBLIC_PB_URL);
pb.autoCancellation(false);
export function initPbFromCookie() {
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/);
if (match) {
pb.authStore.save(match[1], null);
return true;
}
return false;
}
+87
View File
@@ -0,0 +1,87 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export function getSession(event: RequestEvent) {
return event.locals.session;
}
export function requireAuth(event: RequestEvent) {
const session = getSession(event);
if (!session) {
throw redirect(303, '/login');
}
return session;
}
export async function signup(email: string, password: string, famName: string, parentName?: string) {
const res = await fetch(`${HONO_URL}/api/admin/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, famName, parentName }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Signup failed');
return data;
}
export async function login(email: string, password: string) {
console.log(email);
const res = await fetch(`${HONO_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
return data;
}
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
const res = await fetch(`${HONO_URL}/api/members/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, name, deviceToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Join failed');
return data;
}
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
event.cookies.set('session', JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
secure: false,
});
}
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('device_token', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
secure: false,
});
}
export function setPbTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('pb_token', token, {
httpOnly: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24,
secure: false,
});
}
export function clearSession(event: RequestEvent) {
event.cookies.delete('session', { path: '/' });
event.cookies.delete('pb_token', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
}
+111
View File
@@ -0,0 +1,111 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
import type { RequestEvent } from '@sveltejs/kit';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
function sessionHeaders(event: RequestEvent): Record<string, string> {
const s = event.locals.session;
if (!s) return {};
return {
'x-session-famid': s.famId,
'x-session-userid': s.userId,
'Content-Type': 'application/json',
};
}
async function request(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
const res = await fetch(`${HONO_URL}${path}`, {
method,
headers: headers || { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
return data;
}
export const hono = {
admin: {
async list(event: RequestEvent, resource: string, famId: string) {
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
},
async create(event: RequestEvent, resource: string, famId: string, data: Record<string, unknown>) {
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
},
async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
},
async remove(event: RequestEvent, resource: string, famId: string, id: string) {
return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event));
},
async renameFam(event: RequestEvent, famId: string, name: string) {
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
},
async updatePayday(event: RequestEvent, famId: string, payday: number) {
return request('PATCH', `/api/admin/${famId}/fam`, { payday }, sessionHeaders(event));
},
async fam(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
},
async verify(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
},
async regenInvite(event: RequestEvent, famId: string) {
return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event));
},
async weeklySummary(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
},
async completions(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/completions`, undefined, sessionHeaders(event));
},
async rewards(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event));
},
async claimReward(event: RequestEvent, famId: string, rewardId: string) {
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event));
},
async bonusConfigs(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
},
async bonusConfigProgress(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event));
},
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event));
},
async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event));
},
async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record<string, unknown>) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event));
},
async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event));
},
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event));
},
async bonusConfigTallies(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event));
},
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event));
},
async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) {
return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event));
},
async memberChores(event: RequestEvent, famId: string, memberId: string) {
return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event));
},
async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event));
},
async getProfile(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event));
},
async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
},
},
};
+45
View File
@@ -0,0 +1,45 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
let token: string | null = null;
let tokenExpiry = 0;
async function ensureToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
});
const data = await res.json();
if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`);
token = data.token;
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
return token!;
}
export const pbAdmin = {
async getList(collection: string, filter = '') {
const t = await ensureToken();
const params = new URLSearchParams();
if (filter) params.set('filter', filter);
params.set('perPage', '200');
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
return data.items || [];
},
async getOne(collection: string, id: string) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
return data;
},
};
+155
View File
@@ -0,0 +1,155 @@
import { pb } from '$lib/pocketbase';
import type {
Member, ChoreTemplate, AssignedChore, Completion,
WeeklyHistory, Reward, BonusConfig, Fam,
} from '$lib/types';
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards';
class FamStore {
fam = $state<Fam | null>(null)
members = $state<Member[]>([])
templates = $state<ChoreTemplate[]>([])
assigned = $state<AssignedChore[]>([])
completions = $state<Completion[]>([])
history = $state<WeeklyHistory[]>([])
rewards = $state<Reward[]>([])
bonusConfigs = $state<BonusConfig[]>([])
initialized = $state(false)
famId = $state('')
private unsubs: (() => void)[] = []
private destroyed = false
memberMap(): Map<string, Member> {
return new Map(this.members.map((m) => [m.id, m]))
}
templateMap(): Map<string, ChoreTemplate> {
return new Map(this.templates.map((t) => [t.id, t]))
}
bonusConfigMap(): Map<string, BonusConfig> {
return new Map(this.bonusConfigs.map((b) => [b.id, b]))
}
assignedForMember(memberId: string): AssignedChore[] {
return this.assigned.filter((a) => a.memberId === memberId)
}
completionsForDate(date: string): Completion[] {
return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date)
}
isCompleted(assignedChoreId: string, date: string): boolean {
return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date)
}
private initPromise: Promise<void> | null = null
async init(famId: string) {
if (this.initialized && this.famId === famId) return
// Wait for any in-flight init to finish first
if (this.initPromise) {
await this.initPromise
if (this.initialized && this.famId === famId) return
}
this.cleanup()
this.destroyed = false
this.famId = famId
this.initPromise = (async () => {
try {
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] =
await Promise.all([
pb.collection('fams').getOne(famId) as Promise<Fam>,
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<ChoreTemplate[]>,
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<AssignedChore[]>,
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>,
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>,
])
this.fam = famRes
this.members = membersRes
this.templates = templatesRes
this.assigned = assignedRes
this.completions = completionsRes
this.bonusConfigs = bonusConfigsRes
this.rewards = rewardsRes
this.initialized = true
} catch (e) {
console.error('FamStore.init failed:', e)
this.initPromise = null
throw e
}
await this.subscribe()
this.initPromise = null
})()
return this.initPromise!
}
private async subscribe() {
const subs: { collection: CollectionName; filter?: string }[] = [
{ collection: 'members', filter: this.famId },
{ collection: 'chore_templates', filter: this.famId },
{ collection: 'assigned_chores', filter: this.famId },
{ collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId },
{ collection: 'rewards', filter: this.famId },
]
const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : ''
return pb.collection(collection).subscribe('*', (data: any) => {
if (this.destroyed) return
this.handleRealtime(collection, data.action, data.record)
}, { filter: filterStr || undefined }).then((unsub) => {
if (this.destroyed) { unsub(); return }
this.unsubs.push(unsub)
}).catch((err: Error) => {
console.error(`[famStore] subscribe failed for ${collection}:`, err)
})
})
await Promise.allSettled(promises)
}
// Called from form action callbacks for instant UI feedback,
// and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list]
if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
if (action === 'delete') return list.filter((x) => x.id !== record.id)
return list
}
switch (collection) {
case 'members': this.members = apply(this.members); break
case 'chore_templates': this.templates = apply(this.templates); break
case 'assigned_chores': this.assigned = apply(this.assigned); break
case 'completions': this.completions = apply(this.completions); break
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break
case 'rewards': this.rewards = apply(this.rewards); break
}
}
private handleRealtime(collection: string, action: string, record: any) {
this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete')
}
cleanup() {
// TODO - we need to properly unsubscribe from pocketbase
this.destroyed = true
for (const unsub of this.unsubs) unsub()
this.unsubs = []
this.initialized = false
}
}
export const famStore = new FamStore()
+142
View File
@@ -0,0 +1,142 @@
export type Frequency = 'daily' | 'weekly'
export type RewardType = 'points' | 'money'
export type BonusTarget = 'individual' | 'competitive' | 'collaborative'
export type BonusType = 'threshold' | 'count' | 'manual'
export type BonusOccurrence = 'recurring' | 'once'
export type BonusRewardType = 'points' | 'cash' | 'prize'
export type BonusPeriod = 'weekly' | 'monthly'
export type BonusStatus = 'active' | 'archived'
export type BonusState = 'pending' | 'unclaimed' | 'claimed'
export interface Fam {
id: string
name: string
slug: string
inviteCode: string
stripeCustomerId?: string
featureFlags: Record<string, boolean>
created: string
updated: string
}
export interface Member {
id: string
famId: string
name: string
color: string
deviceToken: string
deviceTokenHint: string
created: string
updated: string
}
export interface ChoreTemplate {
id: string
famId: string
name: string
description?: string
defaultFrequency: Frequency
defaultType: RewardType
defaultValue: number
created: string
updated: string
}
export interface AssignedChore {
id: string
famId: string
memberId: string
templateId: string
frequency: Frequency
type: RewardType
value: number
customName?: string
created: string
updated: string
}
export interface Completion {
id: string
famId: string
memberId: string
assignedChoreId: string
date: string
completedAt: string
}
export interface WeeklyHistory {
id: string
famId: string
memberId: string
weekStart: string
pointsEarned: number
moneyEarned: number
choresCompleted: number
bonusEarned: number
}
export interface BonusConfig {
id: string
famId: string
name: string
description?: string
target: BonusTarget
memberId?: string
type: BonusType
occurrence: BonusOccurrence
rewardType: BonusRewardType
rewardValue: string
criteriaValue?: number
period?: BonusPeriod
status: BonusStatus
phase?: 'template' | 'ready' | 'active' | 'completed'
created: string
updated: string
}
export interface Reward {
id: string
famId: string
memberId: string
bonusConfigId?: string
label: string
value: number
rewardType: BonusRewardType
claimed: boolean
claimedAt?: string
date: string
created: string
updated: string
}
export interface BonusProgress {
memberId: string
memberName: string
memberColor: string
current: number
criteriaValue: number
reward: { id: string; claimed: boolean } | null
state: BonusState
achieved: boolean
}
export interface BonusConfigWithProgress {
config: BonusConfig
progress: BonusProgress[]
periodStart: string
periodEnd: string
}
export interface Settings {
id: string
famId: string
webhookUrl?: string
}
export interface Session {
famId: string
userId: string
famSlug: string
memberName?: string
role?: string
}
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
let { error } = $props();
</script>
<div class="error-page">
<h1>Something went wrong</h1>
<p>{error?.message || 'An unexpected error occurred.'}</p>
<p class="hint">Try <a href="/logout">logging out</a> and signing in again.</p>
</div>
<style>
.error-page { max-width: 480px; margin: 4rem auto; text-align: center; 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; }
a { color: #6366f1; }
</style>
@@ -0,0 +1,4 @@
export function load(event) {
const session = event.locals.session || null;
return { session, isParent: session !== null, role: session?.role || 'child' };
}
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { initPbFromCookie } from '$lib/pocketbase';
import { famStore } from '$lib/stores/fam.svelte';
import { Sidebar, TopNav, Footer } from '$lib/components';
import type { Session } from '$lib/types';
let { children, data } = $props();
let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent);
let role = $state(data.role);
let famName = $derived(famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam);
// Claim toast watcher (admin only)
let claimToast = $state('');
let prevClaimedIds = new Set<string>();
let mounted = false;
$effect(() => {
if (!session || !famStore.initialized) return;
const rewards = famStore.rewards;
if (!mounted) {
mounted = true;
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
return;
}
for (const r of rewards) {
if (r.claimed && !prevClaimedIds.has(r.id)) {
const m = famStore.memberMap().get(r.memberId);
const name = m?.name || 'Unknown';
claimToast = `Claim made by ${name}`;
setTimeout(() => claimToast = '', 5000);
}
}
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
});
onMount(() => {
initPbFromCookie();
if (session) {
famStore.init(session.famId);
}
});
</script>
<div class="app-shell">
<Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav />
<main class="app-main">
{@render children()}
</main>
{#if claimToast}
<div class="claim-toast">{claimToast}</div>
{/if}
<Footer />
</div>
<style>
.claim-toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #059669; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; }
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
.app-main {
margin-left: 220px;
margin-top: 48px;
padding: 1.5rem 2rem;
flex: 1;
transition: margin-left 0.2s;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
import { hono } from '$lib/server/hono';
export async function load(event) {
const session = event.locals.session;
if (!session) return {};
const famId = session.famId;
try {
const [members, templates, assigned, summary] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.weeklySummary(event, famId),
]);
return { members, templates, assigned, summary };
} catch {
return {};
}
}
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
let { data } = $props();
let famSlug = $derived(page.params.fam);
let summary = $state(data.summary);
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
let donutCharts: Record<string, Chart> = {};
let choresCanvas: HTMLCanvasElement | undefined;
let choresChart: Chart | undefined;
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
donutCanvases[memberId] = node;
return {
destroy() { delete donutCanvases[memberId]; }
};
}
let members = $state(data.members);
let templates = $state(data.templates);
$effect(() => {
if (data.members?.length) members = data.members;
if (data.templates?.length) templates = data.templates;
if (famStore.initialized) {
if (famStore.members.length) members = famStore.members;
if (famStore.templates.length) templates = famStore.templates;
}
})
onMount(() => {
if (!summary?.summaries?.length) return;
if (choresCanvas && summary.daysInWeek?.length) {
const days = summary.daysInWeek;
const labels = days.map((d: string) =>
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
);
const datasets = summary.summaries.map((s: any) => ({
label: s.memberName,
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
backgroundColor: s.memberColor || '#6366f1',
borderRadius: 4,
}));
choresChart = new Chart(choresCanvas, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
plugins: { legend: { display: true }, title: { display: true, text: 'Chores per day' } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
},
});
}
for (const s of summary.summaries) {
const canvas = donutCanvases[s.memberId];
if (!canvas) continue;
const done = s.choresCompleted || 0;
const total = s.totalChores || 0;
const remaining = Math.max(0, total - done);
donutCharts[s.memberId] = new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Done', 'Remaining'],
datasets: [{
data: [done, remaining],
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
borderWidth: 0,
}],
},
options: {
responsive: true,
cutout: '70%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `${ctx.label}: ${ctx.parsed} chores`,
},
},
},
},
});
}
});
onDestroy(() => {
choresChart?.destroy();
for (const c of Object.values(donutCharts)) c?.destroy();
famStore.cleanup();
});
</script>
<h1>🏠 {famSlug}</h1>
{#if summary?.summaries?.length}
<h2>This week</h2>
<table>
<thead><tr><th>Member</th><th>Points</th><th>Done</th></tr></thead>
<tbody>
{#each summary.summaries as s}
<tr>
<td><span style="color:{s.memberColor}"></span> {s.memberName}</td>
<td>{s.pointsEarned}</td>
<td>{s.choresCompleted}/{s.totalChores}</td>
</tr>
{/each}
</tbody>
</table>
<div class="card">
<h2>Chores per day</h2>
<canvas bind:this={choresCanvas} height="200"></canvas>
</div>
<div class="donut-grid">
{#each summary.summaries as s}
<div class="donut-card">
<canvas use:captureCanvas={s.memberId} height="150" width="150"></canvas>
<div class="donut-label" style="color:{s.memberColor}">{s.memberName}</div>
<div class="donut-points">{s.pointsEarned} pts</div>
<div class="donut-hint">{s.choresCompleted}/{s.totalChores} chores</div>
</div>
{/each}
</div>
{/if}
<h2>Members ({members?.length || 0})</h2>
<div class="member-grid">
{#each members || [] as m}
<a href="/{famSlug}/{m.name}" class="member-card" style="border-left: 4px solid {m.color}">
<strong>{m.name}</strong>
<span class="hint">{m.deviceTokenHint || '—'}</span>
</a>
{/each}
</div>
{#if templates?.length}
<h2>Templates ({templates.length})</h2>
<ul>
{#each templates as t}
<li>{t.name}{t.defaultFrequency}{t.defaultValue} {t.defaultType}</li>
{/each}
</ul>
{/if}
<style>
nav { display: flex; gap: 0.5rem; margin: 1rem 0; }
nav a { padding: 0.4rem 0.8rem; background: #6366f1; color: white; border-radius: 4px; text-decoration: none; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { padding: 0.4rem; border: 1px solid #e5e7eb; text-align: left; }
th { background: #f9fafb; font-weight: 600; }
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
.card h2 { margin: 0 0 0.75rem; font-size: 1rem; }
.card canvas { max-height: 50vh; }
.donut-grid { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0; justify-content: center; }
.donut-card { display: flex; flex-direction: column; align-items: center; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; min-width: 160px; }
.donut-card canvas { max-height: 150px; max-width: 150px; }
.donut-label { font-weight: 600; font-size: 0.9rem; margin-top: 0.5rem; }
.donut-points { font-size: 1.1rem; font-weight: 700; }
.donut-hint { font-size: 0.75rem; color: #9ca3af; }
.member-grid { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.member-card { flex: 1; min-width: 160px; padding: 0.8rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; text-decoration: none; color: inherit; display: block; }
.member-card strong { display: block; }
.hint { font-size: 0.8rem; color: #9ca3af; }
ul { list-style: none; padding: 0; }
li { padding: 0.25rem 0; }
</style>
@@ -0,0 +1,122 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { memberApi } from '$lib/client/api';
import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export async function load(event) {
const session = event.locals.session;
// Parent (session auth) → admin overview
if (session) {
const famId = session.famId;
const famSlug = event.params.fam;
const username = event.params.username;
if (session.memberName && session.memberName !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}`);
}
const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.weeklySummary(event, famId),
hono.admin.fam(event, famId),
hono.admin.rewards(event, famId),
hono.admin.bonusConfigs(event, famId),
hono.admin.completions(event, famId),
]);
return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions };
}
// Child (device token) → kanban
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
const famSlug = event.params.fam;
const username = event.params.username;
if (!deviceToken) {
return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
}
try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const data = await res.json();
if (!res.ok || data.name !== username) {
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
}
let chores: any = {};
try {
chores = await memberApi.myChores(deviceToken, data.famId);
} catch {}
return {
role: 'child',
token: deviceToken,
memberId: data.memberId,
famId: data.famId,
verified: true,
memberName: data.name,
memberColor: data.color,
templates: chores.templates || [],
assigned: chores.assigned || [],
completions: chores.completions || [],
rewards: chores.rewards || [],
bonusConfigs: chores.bonusConfigs || [],
tallies: chores.tallies || {},
};
} catch {
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
}
}
export const actions = {
claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
const message = fd.get('message') as string;
try {
const record = await hono.admin.claimReward(event, famId, rewardId);
if (message) {
await hono.admin.sendMessage(event, famId, record.memberId, message);
}
return { record };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
revoke: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const completionId = fd.get('id') as string;
try {
await hono.admin.revokeCompletion(event, famId, completionId);
return { revoked: true, id: completionId };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
}
},
trigger: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const configId = fd.get('configId') as string;
const memberId = fd.get('memberId') as string;
if (!configId) return { error: 'Config ID required' };
try {
const result = await hono.admin.triggerBonusConfig(event, famId, configId, memberId || undefined);
return { records: result.records || [] };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
}
},
};
@@ -0,0 +1,813 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import { famStore } from '$lib/stores/fam.svelte';
import { memberApi } from '$lib/client/api';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
interface Notification {
id: string
famId: string
memberId: string
message: string
read: boolean
created: string
}
let { data } = $props();
let role = $state(data.role || 'child')
let famSlug = $derived(page.params.fam)
let username = $derived(page.params.username)
// ─── Parent View (admin overview) ───
let summary = $state(data.summary)
let today = $state(new Date().toISOString().slice(0, 10))
const responseTags = ['👏 well done', '😊 really pleased', '🎯 you deserved that', '💪 great effort', '🙏 thanks']
let selectedMessage = $state<Record<string, string>>({})
let customMessage = $state<Record<string, string>>({})
let toast = $state('')
let parentMembers = $derived(famStore.initialized ? famStore.members : (data.members || []))
let parentTemplates = $derived(famStore.initialized ? famStore.templates : (data.templates || []))
let parentAssigned = $derived(famStore.initialized ? famStore.assigned : (data.assigned || []))
let parentRewards = $derived(famStore.initialized ? famStore.rewards : (data.rewards || []))
let parentCompletions = $derived(famStore.initialized ? famStore.completions : (data.completions || []))
let parentBonusConfigs = $derived(famStore.initialized ? famStore.bonusConfigs : (data.bonusConfigs || []))
function totalChoresFor(memberId: string): number {
return parentAssigned.filter((a: any) => a.memberId === memberId).length
}
function todayCompletionsFor(memberId: string) {
return parentCompletions.filter((c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today)
}
function choreNameFor(assignedChoreId: string): string {
const a = parentAssigned.find((x: any) => x.id === assignedChoreId)
if (!a) return '?'
const t = parentTemplates.find((x: any) => x.id === a.templateId)
return a.customName || t?.name || '?'
}
function memberInSummary(memberId: string) {
return summary?.summaries?.find((s: any) => s.memberId === memberId)
}
function claimableRewards() {
return parentRewards.filter((r: any) => !r.claimed)
}
function memberClaimable(memberId: string) {
return claimableRewards().filter((r: any) => r.memberId === memberId)
}
let _confetti: any;
async function fire() {
if (!_confetti) _confetti = await import('@hiseb/confetti').then(m => m.default);
_confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => toast = '', 4000);
}
function handleResult({ result }, onSuccess = null) {
const d = result.data || {};
if (d.error) showToast(d.error);
else if (result.type === 'success') {
if (onSuccess) onSuccess(d);
fire();
}
}
function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`
if (r.rewardType === 'points') return `${r.value} pts`
return r.label
}
function manualConfigs() {
return parentBonusConfigs.filter((b: any) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual')
}
// ─── Child View (kanban) ───
let memberId = $state(data.memberId || '')
let deviceToken = $state(data.token || '')
let famId = $state(data.famId || '')
let memberName = $state(data.memberName || '')
let memberColor = $state(data.memberColor || '#6366f1')
let editingName = $state(false)
let nameInput = $state('')
let showColorPicker = $state(false)
let templates = $state(data.templates as ChoreTemplate[])
let assigned = $state(data.assigned as AssignedChore[])
let completions = $state(data.completions as Completion[])
let rewards = $state(data.rewards as Reward[])
let bonusConfigs = $state(data.bonusConfigs as BonusConfig[])
let loading = $state(true)
let error = $state('')
let dailyPending = $state<AssignedChore[]>([])
let weeklyPending = $state<AssignedChore[]>([])
let completedToday = $state<Completion[]>([])
let claimableRewardsChild = $state<Reward[]>([])
let requestedRewards = $state<string[]>([])
let claimError = $state('')
let todayChild = $state(new Date().toISOString().slice(0, 10))
let togglingIds = $state<string>('')
let bonusNotices = $state<Reward[]>([])
let totalCash = $state(0)
let pendingCash = $state(0)
let totalPoints = $state(0)
let weeklyTotal = $state(0)
let weeklyDone = $state(0)
let bonusProgresses = $state<{ config: BonusConfig; current: number; criteria: number; periodStart: string; periodEnd: string }[]>([])
function weekStartStr(): string {
const d = new Date()
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d.toISOString().slice(0, 10)
}
function monthStartStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`
}
onMount(async () => {
if (role === 'parent') return;
if (!deviceToken) {
deviceToken = localStorage.getItem('deviceToken') || ''
} else {
localStorage.setItem('deviceToken', deviceToken)
}
if (!deviceToken) {
error = 'No device token found. Use the link from your invite.'
loading = false
return
}
if (data.verified && data.famId && data.memberId) {
memberId = data.memberId
famId = data.famId
loading = false
} else {
error = 'Invalid device token. Use the link from your invite.'
loading = false
return
}
await loadAndDismiss()
})
async function loadAndDismiss() {
if (!deviceToken || !famId) return
try {
const res = await fetch('/api/members/notifications', {
headers: {
'x-device-token': deviceToken,
'x-device-famid': famId,
},
})
if (!res.ok) return
const all: Notification[] = await res.json()
for (const n of all.filter(n => !n.read)) {
await fetch(`/api/members/notifications/${n.id}/dismiss`, {
method: 'POST',
headers: {
'x-device-token': deviceToken,
'x-device-famid': famId,
},
})
}
} catch {}
}
$effect(() => {
if (role === 'parent' || !memberId) return
const memberChores = assigned.filter(a => a.memberId === memberId)
dailyPending = memberChores.filter(a => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
weeklyPending = memberChores.filter(a => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
completedToday = completions.filter(c => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
claimableRewardsChild = rewards.filter(r =>
r.memberId === memberId && !r.claimed
)
const ws = weekStartStr()
const dailyChores = memberChores.filter(a => a.frequency === 'daily')
const weeklyChores = memberChores.filter(a => a.frequency === 'weekly')
weeklyTotal = dailyChores.length * 7 + weeklyChores.length
weeklyDone = completions.filter(
c => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws
).length
const myRewards = rewards.filter(r => r.memberId === memberId)
const myCompletions = completions.filter(c => c.memberId === memberId)
bonusNotices = myRewards.filter(r => r.claimed && r.claimedAt?.slice(0, 10) === r.date)
totalCash = myCompletions.reduce((sum, c) => {
const chore = assigned.find(a => a.id === c.assignedChoreId)
return sum + (chore?.type === 'money' ? Number(chore.value) : 0)
}, 0) + myRewards.filter(r => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
pendingCash = myRewards.filter(r => r.rewardType === 'cash' && !r.claimed).reduce((sum, r) => sum + Number(r.value), 0)
totalPoints = myCompletions.reduce((sum, c) => {
const chore = assigned.find(a => a.id === c.assignedChoreId)
return sum + (chore?.type === 'points' ? Number(chore.value) : 0)
}, 0) + myRewards.filter(r => r.rewardType === 'points').reduce((sum, r) => sum + Number(r.value), 0)
const activeConfigs = bonusConfigs.filter(
(b: BonusConfig) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.target === 'individual' && (!b.memberId || b.memberId === memberId)
)
bonusProgresses = activeConfigs.map(cfg => {
const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : ''
const pEnd = pStart ? (() => {
if (cfg.period === 'weekly') {
const d = new Date(pStart)
d.setDate(d.getDate() + 6)
return d.toISOString().slice(0, 10)
}
if (cfg.period === 'monthly') {
const [y, m] = pStart.split('-').map(Number)
const lastDay = new Date(y, m, 0).getDate()
return `${pStart.slice(0, 7)}-${String(lastDay).padStart(2, '0')}`
}
return ''
})() : ''
const periodCompletions = pStart
? myCompletions.filter(c => (c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd)
: myCompletions
let current = 0
if (cfg.type === 'threshold') {
current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find(a => a.id === c.assignedChoreId)
return sum + (chore?.type === 'points' ? Number(chore.value) : 0)
}, 0)
} else if (cfg.type === 'count') {
current = periodCompletions.length
}
return { config: cfg, current, criteria: cfg.criteriaValue || 0, periodStart: pStart, periodEnd: pEnd }
})
})
function isCompleted(assignedChoreId: string, date: string): boolean {
return completions.some(c => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date)
}
function findCompletion(assignedChoreId: string) {
const match = (c: Completion) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild
const optimistic = completions.find(c => match(c) && c.id.startsWith('optimistic-'))
return optimistic || completions.find(match)
}
async function toggle(chore: AssignedChore) {
if (togglingIds) return
togglingIds = chore.id
const wasCompleted = isCompleted(chore.id, todayChild)
if (wasCompleted) {
const existing = findCompletion(chore.id)
if (existing) {
const idx = completions.indexOf(existing)
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
}
} else {
completions = [...completions, {
id: 'optimistic-' + chore.id,
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString(),
} as Completion]
}
try {
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild)
completions = completions.filter(c =>
c.assignedChoreId !== chore.id || (c.date?.slice(0, 10) || c.date) !== todayChild || c.id.startsWith('optimistic-')
)
} catch (e) {
if (wasCompleted) {
completions = [...completions, {
id: 'revert-' + chore.id + '-' + Date.now(),
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString(),
} as Completion]
} else {
const existing = findCompletion(chore.id)
if (existing && existing.id.startsWith('optimistic-')) {
const idx = completions.indexOf(existing)
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
}
}
console.error('Toggle failed:', e)
} finally {
togglingIds = ''
}
}
function templateFor(chore: AssignedChore): ChoreTemplate | undefined {
return templates.find(t => t.id === chore.templateId)
}
function choreName(chore: AssignedChore): string {
return chore.customName || templateFor(chore)?.name || 'Chore'
}
function rewardLabelChild(r: Reward): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`
if (r.rewardType === 'points') return `${r.value} pts`
return r.label
}
</script>
{#if role === 'parent'}
<!-- Parent View: Admin Overview -->
<ViewHeader title={famStore.fam?.name || 'Dashboard'} subtitle={summary?.weekStart ? `Week of ${summary.weekStart}` : ''} />
{#if toast}
<div class="toast">{toast}</div>
{/if}
<CardGrid>
<Card cols={1} title="Members">
{#each parentMembers as m}
{@const total = totalChoresFor(m.id)}
{@const todays = todayCompletionsFor(m.id)}
{@const done = todays.length}
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
{@const s = memberInSummary(m.id)}
<div class="member-card">
<div class="card-header">
<span class="dot" style="background:{m.color}"></span>
<span class="member-name">{m.name}</span>
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
</div>
<div class="stats">
<span>Points: {s?.pointsEarned ?? 0}</span>
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
</div>
<div class="progress-row">
<span class="label">Today:</span>
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="count">{done}/{total}</span>
</div>
{#if done > 0}
<ul class="done-list">
{#each todays as c}
<li>
✅ {choreNameFor(c.assignedChoreId)}
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args, (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); }); }} class="revoke-form">
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
</form>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
</Card>
<Card cols={1} title="Triggers">
{#if manualConfigs().length === 0}
<p class="empty">No manual bonus configs.</p>
{:else}
{#each manualConfigs() as bc}
{@const val = bc.rewardType === 'cash' ? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` : bc.rewardType === 'points' ? `${bc.rewardValue} pts` : bc.rewardValue}
{@const targeted = bc.memberId ? parentMembers.find((m: any) => m.id === bc.memberId) : null}
<div class="trigger-card">
<div class="trigger-head">
<span class="trigger-name">{bc.name}</span>
<span class="trigger-value">{val}</span>
</div>
{#if targeted}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
<input type="hidden" name="configId" value={bc.id} />
<input type="hidden" name="memberId" value={targeted.id} />
<div class="trigger-row">
<span class="trigger-target" style="color:{targeted.color}">{targeted.name}</span>
<Button type="submit" size="sm" variant="primary">Award</Button>
</div>
</form>
{:else}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
<input type="hidden" name="configId" value={bc.id} />
<div class="trigger-row">
<select name="memberId" class="trigger-select">
<option value="">Select member</option>
{#each parentMembers as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" variant="primary">Award</Button>
</div>
</form>
{/if}
</div>
{/each}
{/if}
</Card>
<Card cols={1} title="Claims" accent="#f59e0b">
{#if claimableRewards().length === 0}
<p class="empty">No outstanding claims</p>
{:else}
{#each parentMembers as m}
{@const mc = memberClaimable(m.id)}
{#if mc.length > 0}
<div class="member-claims">
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
{#each mc as r}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<div class="payment-row">
<span>{r.label}</span>
<div class="payment-right">
<span class="value">{rewardLabel(r)}</span>
<Button type="submit" size="sm" variant="primary">Claim</Button>
</div>
</div>
</form>
{/each}
<div class="response-area">
<p class="respond-label">Response:</p>
<div class="tags">
{#each responseTags as tag}
<button
type="button"
class="tag"
class:selected={selectedMessage[m.id] === tag}
onclick={() => selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag}
>{tag}</button>
{/each}
</div>
<input
type="text"
class="custom-msg"
placeholder="Or type your own..."
bind:value={customMessage[m.id]}
oninput={() => { if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; }}
/>
</div>
</div>
{/if}
{/each}
{/if}
</Card>
</CardGrid>
{:else}
<!-- Child View: Member Kanban -->
<ViewHeader title={memberName || username} />
<CardGrid>
<Card cols={3}>
<h1>
<span class="color-dot" style="background:{memberColor}" role="button" tabindex="0"
onclick={e => { e.stopPropagation(); showColorPicker = !showColorPicker }}
onkeydown={e => e.key === 'Enter' && (showColorPicker = !showColorPicker)}></span>
{#if editingName}
<input type="text" bind:value={nameInput} class="name-edit"
onkeydown={async (e: KeyboardEvent) => {
if (e.key === 'Enter') {
editingName = false;
if (nameInput && nameInput !== memberName) {
const old = memberName;
memberName = nameInput;
try {
const res = await fetch('/api/members/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId },
body: JSON.stringify({ name: nameInput }),
});
if (!res.ok) memberName = old;
} catch { memberName = old; }
}
} else if (e.key === 'Escape') {
editingName = false;
nameInput = memberName;
}
}}
onblur={() => { editingName = false; nameInput = memberName; }}
autofocus />
{:else}
<span class="name-display" role="button" tabindex="0"
onclick={() => { nameInput = memberName; editingName = true; }}
onkeydown={e => e.key === 'Enter' && (nameInput = memberName, editingName = true)}>
{memberName || username}
</span>
{/if}
</h1>
{#if showColorPicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="color-picker-overlay" onclick={() => showColorPicker = false}>
<div class="color-picker-popup" onclick={(e) => e.stopPropagation()}>
<p>Pick a color:</p>
<div class="color-swatches">
{#each ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899','#78716c'] as color}
<button class="swatch" style="background:{color}" aria-label={color}
onclick={async () => {
const old = memberColor;
memberColor = color;
showColorPicker = false;
try {
const res = await fetch('/api/members/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId },
body: JSON.stringify({ color }),
});
if (!res.ok) memberColor = old;
} catch { memberColor = old; }
}}></button>
{/each}
</div>
</div>
</div>
{/if}
{#if loading}
<p>Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else}
<!-- Badge Cards -->
<div class="badges-row">
<div class="badge-card notice">
<span class="badge-label">Bonus Notices</span>
<span class="badge-value">{bonusNotices.length}</span>
{#if bonusNotices.length > 0}
<span class="badge-sub">+{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts</span>
{/if}
</div>
<div class="badge-card cash">
<span class="badge-label">Total Cash</span>
<span class="badge-value">£{(totalCash / 100).toFixed(2)}</span>
</div>
<div class="badge-card pending">
<span class="badge-label">Pending Cash</span>
<span class="badge-value">£{(pendingCash / 100).toFixed(2)}</span>
</div>
<div class="badge-card points">
<span class="badge-label">Total Points</span>
<span class="badge-value">{totalPoints}</span>
</div>
</div>
<!-- Weekly Progress -->
{#if weeklyTotal > 0}
{@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)}
<div class="week-card">
<div class="week-head">
<span class="week-label">This Week</span>
<span class="week-stat">{weeklyDone}/{weeklyTotal} done</span>
</div>
<div class="bar-wrap lg">
<div class="bar-fill" style="width:{wpct}%"></div>
</div>
</div>
{/if}
<!-- Bonus Progress -->
{#if bonusProgresses.length > 0}
<div class="bonus-grid">
{#each bonusProgresses as bp}
{@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0}
{@const hasReward = bp.periodStart ? rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id && r.date >= bp.periodStart && r.date <= bp.periodEnd) : rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id)}
{@const typeClass = bp.config.type === 'threshold' ? 'type-threshold' : bp.config.type === 'count' ? 'type-count' : 'type-manual'}
<div class="bonus-badge {typeClass}" class:achieved={hasReward}>
<div class="bonus-head">
<span class="bonus-name">{bp.config.name}</span>
<span class="bonus-type">{bp.config.type}</span>
</div>
<div class="bonus-body">
{#if bp.criteria > 0}
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="bonus-stat">{bp.current}/{bp.criteria} {bp.config.type === 'threshold' ? 'pts' : 'chores'}</span>
{/if}
</div>
{#if hasReward}
<span class="bonus-done">Reward earned 🎉</span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Kanban -->
<div class="kanban">
<div class="column">
<h2>Daily Pending ({dailyPending.length})</h2>
{#if dailyPending.length === 0}
<p class="empty">All done!</p>
{:else}
{#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{/if}
</div>
<div class="column">
<h2>Weekly Pending ({weeklyPending.length})</h2>
{#if weeklyPending.length === 0}
<p class="empty">All done!</p>
{:else}
{#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{/if}
</div>
<div class="column">
<h2>Completed Today ({completedToday.length})</h2>
{#if completedToday.length === 0}
<p class="empty">Nothing yet</p>
{:else}
{#each completedToday as c}
{@const chore = assigned.find(a => a.id === c.assignedChoreId)}
{#if chore}
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/if}
{/each}
{/if}
</div>
<div class="column claim-col">
<h2>Claimable ({claimableRewardsChild.length})</h2>
{#if claimableRewardsChild.length === 0}
<p class="empty">Nothing to claim</p>
{:else}
{#each claimableRewardsChild as r}
<div class="claim-card">
<div class="claim-body">
<span class="claim-label">{r.label}</span>
<span class="claim-value">{rewardLabelChild(r)}</span>
</div>
<div class="claim-actions">
<span class="badge">{r.rewardType}</span>
<button class="request-btn" onclick={async () => { try { await memberApi.claimReward(deviceToken, famId, r.id); requestedRewards = [...requestedRewards, r.id]; } catch (e) { claimError = e instanceof Error ? e.message : 'Claim failed'; } }} disabled={requestedRewards.includes(r.id)}>
{requestedRewards.includes(r.id) ? 'Requested' : 'Request'}
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</Card>
</CardGrid>
{/if}
<style>
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; }
@keyframes fadein { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.empty { color: #9ca3af; font-size: 0.85rem; text-align: center; padding: 1.5rem; }
.member-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem; background: white; margin-bottom: 0.6rem; }
.member-card:last-child { margin-bottom: 0; }
.card-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.member-name { font-weight: 600; flex: 1; font-size: 0.9rem; }
.link { font-size: 0.8rem; color: #6366f1; text-decoration: none; }
.stats { display: flex; gap: 0.75rem; font-size: 0.8rem; color: #6b7280; margin-bottom: 0.4rem; }
.progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
.label { font-size: 0.75rem; color: #6b7280; min-width: 2.5rem; }
.bar-wrap { flex: 1; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; background: #6366f1; border-radius: 4px; transition: width 0.3s; }
.count { font-size: 0.75rem; color: #6b7280; min-width: 3rem; text-align: right; }
.done-list { list-style: none; padding: 0; margin: 0; }
.done-list li { font-size: 0.75rem; padding: 0.05rem 0; color: #059669; display: flex; align-items: center; gap: 0.25rem; }
.revoke-form { display: inline; }
.revoke-btn { background: none; border: 1px solid transparent; border-radius: 3px; cursor: pointer; padding: 0 2px; font-size: 0.75rem; line-height: 1.2; color: #9ca3af; }
.revoke-btn:hover { color: #dc2626; border-color: #fca5a5; background: #fef2f2; }
.trigger-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem; background: #f0fdf4; margin-bottom: 0.6rem; border-left: 3px solid #22c55e; }
.trigger-card:last-child { margin-bottom: 0; }
.trigger-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem; }
.trigger-name { font-weight: 600; font-size: 0.9rem; }
.trigger-value { font-size: 0.85rem; color: #059669; font-weight: 600; }
.trigger-row { display: flex; gap: 0.4rem; }
.trigger-select { flex: 1; padding: 0.35rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.8rem; }
.member-claims { margin-bottom: 0.75rem; }
.member-claims:last-child { margin-bottom: 0; }
.member-claims h4 { display: flex; align-items: center; gap: 0.3rem; margin: 0 0 0.2rem; font-size: 0.85rem; }
.payment-row { display: flex; justify-content: space-between; font-size: 0.8rem; padding: 0.1rem 0; }
.payment-row .value { font-weight: 600; color: #059669; }
.payment-right { display: flex; align-items: center; gap: 0.4rem; }
.respond-label { font-size: 0.8rem; color: #6b7280; margin: 0.4rem 0 0.3rem; }
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; border-radius: 999px; background: white; cursor: pointer; }
.tag:hover { background: #f3f4f6; }
.tag.selected { background: #6366f1; color: white; border-color: #6366f1; }
.custom-msg { margin-top: 0.3rem; padding: 0.3rem 0.5rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.8rem; width: 100%; box-sizing: border-box; }
.error { color: #dc2626; padding: 1rem; text-align: center; }
.badges-row { display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
.badge-card { flex: 1; min-width: 120px; padding: 0.75rem; border-radius: 8px; background: #f9fafb; border: 1px solid #e5e7eb; }
.badge-label { font-size: 0.75rem; color: #6b7280; display: block; }
.badge-value { font-size: 1.4rem; font-weight: 700; display: block; }
.badge-sub { font-size: 0.75rem; color: #059669; }
.badge-card.notice { border-left: 3px solid #f59e0b; }
.badge-card.cash { border-left: 3px solid #10b981; }
.badge-card.pending { border-left: 3px solid #3b82f6; }
.badge-card.points { border-left: 3px solid #8b5cf6; }
.week-card { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; }
.week-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.week-label { font-weight: 700; font-size: 0.95rem; color: #166534; }
.week-stat { font-size: 0.8rem; color: #16a34a; font-weight: 600; }
.bar-wrap.lg { height: 14px; }
.bonus-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; margin-bottom: 1rem; }
.bonus-badge { border-radius: 10px; padding: 0.7rem; background: white; border: 1px solid #e5e7eb; border-left: 4px solid #9ca3af; }
.bonus-badge.type-threshold { border-left-color: #8b5cf6; }
.bonus-badge.type-count { border-left-color: #3b82f6; }
.bonus-badge.type-manual { border-left-color: #f59e0b; }
.bonus-badge.achieved { background: #f0fdf4; border-color: #bbf7d0; border-left-color: #22c55e; }
.bonus-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.3rem; }
.bonus-name { font-size: 0.8rem; font-weight: 600; }
.bonus-type { font-size: 0.6rem; padding: 1px 6px; border-radius: 6px; background: #f3f4f6; color: #6b7280; text-transform: capitalize; }
.bonus-body { display: flex; align-items: center; gap: 0.5rem; }
.bonus-body .bar-wrap { flex: 1; height: 8px; }
.bonus-stat { font-size: 0.7rem; color: #6b7280; white-space: nowrap; font-weight: 600; min-width: 70px; text-align: right; }
.bonus-done { font-size: 0.7rem; color: #16a34a; font-weight: 600; margin-top: 0.2rem; display: block; }
.kanban { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 0.75rem; margin-top: 0.5rem; }
.column { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.75rem; background: #f9fafb; }
.column h2 { margin: 0 0 0.75rem; font-size: 0.95rem; }
.chore { display: flex; align-items: center; gap: 0.4rem; width: 100%; padding: 0.5rem 0.6rem; margin-bottom: 0.4rem; border: 1px solid #e5e7eb; border-radius: 6px; background: white; cursor: pointer; text-align: left; font-size: 0.85rem; }
.chore:hover { background: #f3f4f6; }
.chore:disabled { opacity: 0.5; cursor: not-allowed; }
.done { opacity: 0.65; }
.checkbox { font-size: 1rem; flex-shrink: 0; }
.chore-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chore-value { font-size: 0.75rem; color: #6b7280; font-weight: 600; white-space: nowrap; }
.claim-col { border-left: 3px solid #10b981; }
.claim-card { display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem; margin-bottom: 0.4rem; border: 1px solid #e5e7eb; border-radius: 6px; background: white; }
.claim-body { flex: 1; }
.claim-label { font-size: 0.8rem; display: block; }
.claim-value { font-size: 0.9rem; font-weight: 700; color: #059669; }
.claim-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; }
.badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 8px; background: #d1fae5; color: #059669; white-space: nowrap; }
.request-btn { font-size: 0.7rem; padding: 2px 8px; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
.request-btn:disabled { background: #9ca3af; cursor: default; }
h1 { display: flex; align-items: center; gap: 0.5rem; }
.color-dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; border: 2px solid #e5e7eb; cursor: pointer; flex-shrink: 0; }
.name-display { cursor: pointer; border-bottom: 1px dashed #d1d5db; }
.name-edit { font-size: 1.2rem; padding: 0.1rem 0.3rem; border: 1px solid #6366f1; border-radius: 4px; outline: none; font-weight: 700; }
.color-picker-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.3); }
.color-picker-popup { background: white; padding: 1.25rem; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); }
.color-picker-popup p { margin: 0 0 0.75rem; font-weight: 600; font-size: 0.9rem; }
.color-swatches { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.swatch { width: 36px; height: 36px; border-radius: 50%; border: 2px solid #e5e7eb; cursor: pointer; }
.swatch:hover { border-color: #6366f1; transform: scale(1.1); }
</style>
@@ -0,0 +1,186 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [configs, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'members', famId),
hono.admin.bonusConfigProgress(event, famId),
hono.admin.rewards(event, famId),
]);
return { configs, members, progress, rewards };
}
export const actions = {
createConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const phase = fd.get('phase') as string;
const data: Record<string, unknown> = {
name: fd.get('name'),
target: fd.get('target'),
type: fd.get('type'),
occurrence: fd.get('occurrence'),
rewardType: fd.get('rewardType'),
rewardValue: fd.get('rewardValue'),
phase: phase || 'ready',
};
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
const description = fd.get('description');
if (description) data.description = description;
const memberId = fd.get('memberId');
if (memberId) data.memberId = memberId;
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
}
},
updateConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
const name = fd.get('name');
if (name) data.name = name;
const target = fd.get('target');
if (target) data.target = target;
const type = fd.get('type');
if (type) data.type = type;
const occurrence = fd.get('occurrence');
if (occurrence) data.occurrence = occurrence;
const rewardType = fd.get('rewardType');
if (rewardType) data.rewardType = rewardType;
const rewardValue = fd.get('rewardValue');
if (rewardValue) data.rewardValue = rewardValue;
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
const description = fd.get('description');
if (description) data.description = description;
const memberId = fd.get('memberId');
if (memberId !== null) data.memberId = memberId || null;
try {
const record = await hono.admin.update(event, 'bonus-configs', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
}
},
deleteConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.remove(event, 'bonus-configs', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' });
}
},
createFromTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const templateId = fd.get('templateId') as string;
try {
const templates = await hono.admin.bonusConfigs(event, famId);
const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId);
if (!tmpl) return fail(400, { error: 'Template not found' });
const data: Record<string, unknown> = {
name: tmpl.name,
description: tmpl.description || '',
target: tmpl.target,
type: tmpl.type,
occurrence: tmpl.occurrence,
rewardType: tmpl.rewardType,
rewardValue: tmpl.rewardValue,
criteriaValue: tmpl.criteriaValue || 0,
period: tmpl.period || '',
memberId: tmpl.memberId || '',
phase: 'ready',
};
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' });
}
},
assignConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const target = fd.get('target') as string;
const memberId = fd.get('memberId') as string;
try {
const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null });
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' });
}
},
completeConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const phase = fd.get('phase') as string;
try {
const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed');
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' });
}
},
destroyConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.destroyBonusConfig(event, famId, id);
return { destroyed: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
}
},
claimReward: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
await hono.admin.claimReward(event, famId, rewardId);
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
evaluate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
try {
await hono.admin.evaluateBonusConfig(event, famId);
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
}
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [templates, members, assigned] = await Promise.all([
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId),
]);
return { templates, members, assigned };
}
export const actions = {
createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data = {
name: fd.get('name'),
defaultFrequency: fd.get('defaultFrequency'),
defaultType: fd.get('defaultType'),
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0,
};
if (!data.name || !data.defaultFrequency || !data.defaultType) {
return fail(400, { error: 'Name, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'chore-templates', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
}
},
updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const defaultFrequency = fd.get('defaultFrequency') as string;
const defaultType = fd.get('defaultType') as string;
const defaultValue = parseFloat(fd.get('defaultValue') as string) || 0;
const data = {
name: fd.get('name'),
defaultFrequency,
defaultType,
defaultValue,
};
try {
const record = await hono.admin.update(event, 'chore-templates', famId, id, data);
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId);
if (Array.isArray(allAssigned)) {
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
await Promise.all(toUpdate.map((a: any) =>
hono.admin.update(event, 'assigned-chores', famId, a.id, {
frequency: defaultFrequency,
type: defaultType,
value: defaultValue,
})
));
}
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
}
},
deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.remove(event, 'chore-templates', famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
}
},
assignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data = {
memberId: fd.get('memberId'),
templateId: fd.get('templateId'),
frequency: fd.get('frequency'),
type: fd.get('type'),
value: parseFloat(fd.get('value') as string) || 0,
customName: fd.get('customName') || undefined,
};
if (!data.memberId || !data.templateId || !data.frequency || !data.type) {
return fail(400, { error: 'Member, template, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' });
}
},
updateAssignedChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
const frequency = fd.get('frequency');
const type = fd.get('type');
const value = fd.get('value');
const customName = fd.get('customName');
if (frequency) data.frequency = frequency;
if (type) data.type = type;
if (value) data.value = parseFloat(value as string) || 0;
data.customName = (customName as string) || undefined;
try {
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
}
},
unassignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.remove(event, 'assigned-chores', famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' });
}
},
};
@@ -0,0 +1,402 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { ChoreTemplate, AssignedChore, Member } from '$lib/types';
let { data, form } = $props();
let showCreateModal = $state(false)
let showEditModal = $state(false)
let showEditTemplateModal = $state(false)
let editingAssignment = $state<AssignedChore | null>(null)
let editingTemplate = $state<ChoreTemplate | null>(null)
let editMemberId = $state('')
let draggedTemplateId = $state<string | null>(null)
let createName = $state('')
let createFreq = $state('daily')
let createType = $state('points')
let createValue = $state(10)
let editFreq = $state('daily')
let editType = $state('points')
let editValue = $state(10)
let editCustomName = $state('')
let editTplName = $state('')
let editTplFreq = $state('daily')
let editTplType = $state('points')
let editTplValue = $state(10)
let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || []))
let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || []))
let assigned = $state(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
function assignedForMember(memberId: string): AssignedChore[] {
return assigned.filter(a => a.memberId === memberId)
}
function templateName(templateId: string): string {
return famStore.templateMap().get(templateId)?.name || '?'
}
function getDefault(templateId: string): { frequency: string; type: string; value: number } {
const t = famStore.templateMap().get(templateId)
if (!t) return { frequency: 'daily', type: 'points', value: 10 }
return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue }
}
function handleDragStart(e: DragEvent, templateId: string) {
draggedTemplateId = templateId
e.dataTransfer?.setData('text/plain', templateId)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
async function handleDrop(e: DragEvent, memberId: string) {
e.preventDefault()
const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId
if (!tid) return
draggedTemplateId = null
const def = getDefault(tid)
const fd = new FormData()
fd.set('memberId', memberId)
fd.set('templateId', tid)
fd.set('frequency', def.frequency)
fd.set('type', def.type)
fd.set('value', String(def.value))
const res = await fetch(`/${page.params.fam}/${page.params.username}/chores?/assignChore`, {
method: 'POST',
body: fd,
})
if (res.ok) {
const body = await res.json()
if (body.record) {
assigned = [body.record as AssignedChore, ...assigned]
famStore.applyRecord('assigned_chores', body.record, 'create')
}
}
}
function openEdit(a: AssignedChore) {
editingAssignment = a
editFreq = a.frequency
editType = a.type
editValue = a.value
editCustomName = a.customName || ''
showEditModal = true
}
function closeEdit() {
showEditModal = false
editingAssignment = null
}
function openEditTemplate(t: ChoreTemplate) {
editingTemplate = t
editTplName = t.name
editTplFreq = t.defaultFrequency
editTplType = t.defaultType
editTplValue = t.defaultValue
showEditTemplateModal = true
}
function closeEditTemplate() {
showEditTemplateModal = false
editingTemplate = null
}
function enhanceCreate() {
return async ({ result, formData }: any) => {
if (result.type === 'success') {
showCreateModal = false
const rec = result.data?.record
if (rec) {
templates = [rec as ChoreTemplate, ...templates]
famStore.applyRecord('chore_templates', rec, 'create')
}
}
}
}
function enhanceUpdateAssigned() {
return async ({ result }: any) => {
if (result.type === 'success' && editingAssignment) {
const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id)
if (idx !== -1) {
assigned[idx] = {
...assigned[idx],
frequency: editFreq,
type: editType,
value: editValue,
customName: editCustomName || undefined,
} as AssignedChore
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
}
}
closeEdit()
}
}
function enhanceUpdateTemplate() {
return async ({ result, formData }: any) => {
if (result.type === 'success' && editingTemplate) {
const tid = editingTemplate!.id
const idx = templates.findIndex((t: any) => t.id === tid)
if (idx !== -1) {
templates[idx] = {
...templates[idx],
name: editTplName,
defaultFrequency: editTplFreq,
defaultType: editTplType,
defaultValue: editTplValue,
} as ChoreTemplate
}
assigned = assigned.map((a: any) =>
a.templateId === tid
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
: a
) as AssignedChore[]
famStore.applyRecord('chore_templates', templates[idx], 'update')
closeEditTemplate()
}
}
}
</script>
<ViewHeader title="Chore Assignment" />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<CardGrid>
<Card cols={3}>
<div style="margin-bottom:0.75rem">
<Button onclick={() => showCreateModal = true}>+ New Chore Template</Button>
</div>
<div class="kanban">
<div class="column templates-col">
<h3>Templates</h3>
{#each templates as t}
<div
class="card template"
draggable="true"
ondragstart={(e) => handleDragStart(e, t.id)}
>
<div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0">
<strong>{t.name}</strong>
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
</div>
<div class="card-value">
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
</div>
<div class="card-actions">
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template">✎</button>
<form method="POST" action="?/deleteTemplate" use:enhance={() => { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } }; }}>
<input type="hidden" name="id" value={t.id} />
<button type="submit" class="del-btn" title="Delete template">×</button>
</form>
</div>
</div>
{/each}
{#if templates.length === 0}
<p class="empty">No templates yet</p>
{/if}
</div>
{#each members as m}
<div
class="column member-col"
style="border-top-color: {m.color}"
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)}
>
<h3>
<span class="dot" style="background:{m.color}"></span>
{m.name}
</h3>
{#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)}
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
<div class="card-body">
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
</div>
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}>
<input type="hidden" name="id" value={a.id} />
<button type="submit" class="del-btn" title="Remove assignment">×</button>
</form>
</div>
{/each}
{#if assignedForMember(m.id).length === 0}
<p class="empty">Drop a chore here</p>
{/if}
</div>
{/each}
</div>
</Card></CardGrid>
<!-- Create Template Modal -->
{#if showCreateModal}
<div class="overlay" onclick={() => showCreateModal = false} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>New Chore Template</h3>
<form method="POST" action="?/createTemplate" use:enhance={enhanceCreate}>
<label>
Name
<input name="name" bind:value={createName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={createFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={createType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={createValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={() => showCreateModal = false}>Cancel</button>
<button type="submit">Create</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Template Modal -->
{#if showEditTemplateModal && editingTemplate}
<div class="overlay" onclick={closeEditTemplate} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit Template: {editingTemplate.name}</h3>
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
<form method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
<input name="id" type="hidden" value={editingTemplate.id} />
<label>
Name
<input name="name" bind:value={editTplName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={editTplFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={editTplType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={closeEditTemplate}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Assignment Modal -->
{#if showEditModal && editingAssignment}
<div class="overlay" onclick={closeEdit} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3>
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
<input name="id" type="hidden" value={editingAssignment.id} />
<label>
Custom Name
<input name="customName" bind:value={editCustomName} placeholder="Override template name" />
</label>
<label>
Frequency
<select name="frequency" bind:value={editFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="type" bind:value={editType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Value
<input name="value" type="number" step="any" bind:value={editValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={closeEdit}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; margin: 0.5rem 0; }
.kanban { display: flex; gap: 1rem; overflow-x: auto; align-items: flex-start; }
.column { flex: 1; min-width: 200px; max-width: 280px; border: 2px solid #e5e7eb; border-top-width: 4px; border-radius: 8px; padding: 0.75rem; background: #fafafa; min-height: 200px; }
.column h3 { margin: 0 0 0.75rem; font-size: 1rem; display: flex; align-items: center; gap: 0.4rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.empty { color: #9ca3af; font-size: 0.85rem; text-align: center; padding: 1rem; border: 1px dashed #d1d5db; border-radius: 6px; }
.card { background: white; border: 1px solid #e5e7eb; border-radius: 6px; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem; position: relative; }
.card-body { flex: 1; min-width: 0; }
.card-body strong { display: block; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-value { font-size: 0.85rem; color: #6b7280; font-weight: 600; white-space: nowrap; }
.badge { font-size: 0.7rem; background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 10px; margin-right: 4px; }
.badge.type { background: #fce7f3; color: #be185d; }
.card-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.edit-btn, .del-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1rem; padding: 0 4px; line-height: 1; flex-shrink: 0; }
.edit-btn:hover { color: #6366f1; }
.del-btn:hover { color: #dc2626; }
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
.template { cursor: grab; border-left: 3px solid #6366f1; }
.template:active { cursor: grabbing; opacity: 0.8; }
.assigned { cursor: pointer; border-left: 3px solid #10b981; }
.assigned:hover { background: #f3f4f6; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
.modal h3 { margin: 0 0 1rem; }
.modal label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; color: #374151; }
.modal input, .modal select { display: block; width: 100%; margin-top: 0.25rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.modal-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.modal-actions button[type="button"] { background: #e5e7eb; color: #374151; }
.modal-actions button[type="submit"] { background: #6366f1; color: white; }
</style>
@@ -0,0 +1,99 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export async function load(event) {
const session = event.locals.session;
const famSlug = event.params.fam;
const username = event.params.username;
// Parent (session auth) — profile lives in fam_admins
if (session) {
const famId = session.famId;
if (session.memberName && session.memberName !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`);
}
try {
const me = await hono.admin.getProfile(event, famId);
return {
verified: true, token: '', memberId: me.id, famId,
memberName: me.name, memberColor: me.color, email: me.email || '',
session: true,
};
} catch {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
}
// Child (device token) — profile lives in members
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
if (!deviceToken) {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const data = await res.json();
if (!res.ok || data.name !== username) {
return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
return {
verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId,
memberName: data.name, memberColor: data.color, email: data.email || '', session: false,
};
} catch {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
}
export const actions = {
update: async (event) => {
const session = event.locals.session;
const fd = await event.request.formData();
const name = fd.get('name') as string;
const color = fd.get('color') as string;
const email = fd.get('email') as string;
if (session) {
const famId = session.famId;
try {
const data: Record<string, string> = {};
if (name) data.name = name;
if (color) data.color = color;
data.email = email || '';
const me = await hono.admin.updateProfile(event, famId, data);
return { success: true, name: me.name, color: me.color, email: me.email };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' };
}
}
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
const famSlug = event.params.fam;
if (!deviceToken) return { error: 'Not authenticated' };
try {
const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const verify = await verifyRes.json();
if (!verifyRes.ok) return { error: 'Verification failed' };
const res = await fetch(`${HONO_URL}/api/members/me`, {
method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color }),
});
const data = await res.json();
if (!res.ok) return { error: data.error || 'Update failed' };
return { success: true, name: data.name, color: data.color };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' };
}
},
};
@@ -0,0 +1,99 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
let { data, form } = $props();
let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1');
let memberEmail = $state(data.email || '');
const colors = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
</script>
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} />
{#if !data.verified}
<CardGrid>
<Card cols={3}>
<p style="color:#9ca3af;text-align:center;padding:2rem">
Not authenticated. Use the link from your invite.
</p>
</Card>
</CardGrid>
{:else}
<CardGrid>
<Card cols={2} title="Appearance">
<form method="POST" action="?/update" use:enhance>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Display Name
</label>
<input
type="text"
name="name"
bind:value={memberName}
required
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
/>
</div>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Color
</label>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
{#each colors as c}
<button
type="button"
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor ? '3px solid #111827' : '2px solid transparent'};background:{c};cursor:pointer"
onclick={() => memberColor = c}
></button>
{/each}
</div>
<input type="hidden" name="color" value={memberColor} />
</div>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Email
</label>
{#if data.session}
<input
type="email"
name="email"
bind:value={memberEmail}
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>
{#if form?.error}
<p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p>
{/if}
{#if form?.success}
<p style="color:#059669;font-size:0.85rem;margin-bottom:0.5rem">Saved!</p>
{/if}
<Button type="submit" variant="primary">Save</Button>
</form>
</Card>
<Card cols={1} title="Account">
<p style="font-size:0.85rem;color:#6b7280">
To change your device or sign in on a new device, use your invite link.
</p>
<p style="font-size:0.85rem;color:#6b7280;margin-top:0.5rem">
Contact your family admin to change your token or reset your account.
</p>
</Card>
</CardGrid>
{/if}
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [rewards, members] = await Promise.all([
hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId),
]);
return { rewards, members };
}
export const actions = {
claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
const record = await hono.admin.claimReward(event, famId, rewardId);
return { record };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
};
@@ -0,0 +1,137 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
let { data } = $props();
let rewards = $state(famStore.initialized ? famStore.rewards : (data.rewards || []))
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
function memberName(memberId: string): string {
return famStore.memberMap().get(memberId)?.name || members.find((m: any) => m.id === memberId)?.name || '?';
}
function memberColor(memberId: string): string {
return famStore.memberMap().get(memberId)?.color || members.find((m: any) => m.id === memberId)?.color || '#6366f1';
}
function rewardAmount(r: any): string {
if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return r.label;
}
function rewardStatus(r: any): string {
if (!r.claimed) return 'Outstanding';
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed';
}
function isOutstanding(r: any): boolean { return !r.claimed; }
let sorted = $derived([...rewards].sort((a: any, b: any) => {
const da = a.date || a.id || '';
const db = b.date || b.id || '';
return da < db ? 1 : da > db ? -1 : 0;
}))
let toast = $state('')
async function fire() {
const { default: confetti } = await import('@hiseb/confetti');
confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => toast = '', 4000);
}
let totalOutstanding = $derived(
rewards
.filter((r: any) => isOutstanding(r))
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
)
</script>
<ViewHeader title="Reward Ledger" subtitle="All rewards earned by members" />
{#if toast}
<div class="toast">{toast}</div>
{/if}
<CardGrid>
<Card cols={3}>
{#if rewards.length === 0}
<p style="color:#9ca3af;text-align:center;padding:2rem">No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.</p>
{:else}
<table>
<thead>
<tr>
<th>Date</th>
<th>Member</th>
<th>Description</th>
<th class="num">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each sorted as r}
{@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding}>
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
<td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
{memberName(r.memberId)}
</td>
<td>{r.label}</td>
<td class="num">{rewardAmount(r)}</td>
<td>
<span class="status-badge" class:outstanding class:claimed-status={!outstanding}>
{rewardStatus(r)}
</span>
</td>
<td>
{#if outstanding}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}>
<input name="id" type="hidden" value={r.id} />
<Button type="submit" size="sm">Claim</Button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td></td>
<td></td>
<td class="num"><strong>Outstanding cash</strong></td>
<td class="num"><strong>£{(totalOutstanding / 100).toFixed(2)}</strong></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
{/if}
</Card>
</CardGrid>
<style>
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #e5e7eb; text-align: left; }
th { background: #f9fafb; font-weight: 600; position: sticky; top: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
tr.claimed { opacity: 0.5; }
tr.claimed td { color: #9ca3af; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
</style>
@@ -0,0 +1,57 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import type { RequestEvent } from '@sveltejs/kit';
export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [members, fam] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.fam(event, famId),
]);
return { members, fam };
}
export const actions = {
regenInvite: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode };
},
addMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) return { error: 'Name required' };
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' });
},
renameFam: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) return { error: 'Name required' };
return await hono.admin.renameFam(event, famId, name);
},
deleteMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
if (!id) return { error: 'Member ID required' };
await hono.admin.remove(event, 'members', famId, id);
},
updatePayday: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const payday = parseInt(fd.get('payday') as string, 10);
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
return await hono.admin.updatePayday(event, famId, payday);
},
};
@@ -0,0 +1,171 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { onDestroy } from 'svelte';
import QRCode from 'qrcode';
let { data } = $props();
let fam = $state(data.fam)
let famSlug = $state(page.params.fam)
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`)
let addName = $state('')
let rename = $state('')
let selectedMember = $state('')
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1)
let showQR = $state(false)
let qrDataUrl = $state('')
let copied = $state(false)
let parentInviteEmail = $state('')
let selectedInviteUrl = $derived(
selectedMember
? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}`
: childInviteUrl
)
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
function copy(url: string) {
navigator.clipboard.writeText(url)
copied = true
setTimeout(() => copied = false, 2000)
}
async function generateQR(url: string) {
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 })
}
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail)
}
</script>
<ViewHeader title="Settings" />
<CardGrid>
<Card title="Family Name">
<form method="POST" action="?/renameFam" use:enhance>
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
</Card>
<Card title="Members ({members.length})" cols={2}>
<form method="POST" action="?/addMember" use:enhance>
<input name="name" bind:value={addName} placeholder="Member name" required />
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each members as m}
<li>
<span class="dot" style="background:{m.color}"></span>
{m.name}
{m.deviceToken ? '' : ' (pending join)'}
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
</form>
</li>
{/each}
</ul>
</Card>
<Card title="Payday" cols={1}>
<form method="POST" action="?/updatePayday" use:enhance>
<select name="payday" bind:value={payday}>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
<Button type="submit" size="sm">Set payday</Button>
</form>
<p class="hint">The week starts on this day for tracking weekly chores and bonus cycles.</p>
</Card>
<Card title="Invite Children" cols={1}>
<p class="code">{fam?.inviteCode || '...'}</p>
<div class="invite-row">
<label>Member:</label>
<select bind:value={selectedMember}>
<option value="">— General link —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
</div>
<p class="hint">Re-joining a member will disconnect their old device.</p>
<p class="invite-url">{selectedInviteUrl}</p>
<div class="actions">
<Button onclick={() => copy(selectedInviteUrl)} size="sm">
{copied ? 'Copied!' : 'Copy link'}
</Button>
<Button onclick={async () => { showQR = !showQR; if (!showQR) { qrDataUrl = ''; } else { await generateQR(selectedInviteUrl); } }} size="sm">
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
<form method="POST" action="?/regenInvite" use:enhance class="inline">
<Button type="submit" size="sm">Renew code</Button>
</form>
</div>
{#if showQR && qrDataUrl}
<img src={qrDataUrl} alt="QR Code" class="qr" />
{/if}
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-row">
<input
type="email"
bind:value={parentInviteEmail}
placeholder="parent@example.com"
/>
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
</Card>
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
</CardGrid>
<style>
.section { margin: 0.5rem 0; }
.code { font-family: monospace; font-size: 1.2rem; padding: 0.5rem; background: #f3f4f6; border-radius: 4px; display: inline-block; }
.invite-url { font-family: monospace; font-size: 0.9rem; word-break: break-all; background: #f9fafb; padding: 0.4rem; border-radius: 4px; }
.invite-row { display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0; }
.invite-row select { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
.invite-row input { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
.hint { font-size: 0.85rem; color: #9ca3af; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; }
.qr { margin-top: 0.5rem; border: 1px solid #e5e7eb; border-radius: 4px; }
ul { list-style: none; padding: 0; }
li { padding: 0.3rem 0; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.link { font-size: 0.85rem; color: #6366f1; }
form { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
input, select, button { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; }
button { background: #6366f1; color: white; border: none; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
.inline { display: inline; margin: 0; }
</style>
+23
View File
@@ -0,0 +1,23 @@
import { pbAdmin } from '$lib/server/pb-admin';
export async function load() {
const fams = await pbAdmin.getList('fams');
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
const [members, rewards] = await Promise.all([
pbAdmin.getList('members', `famId = '${fam.id}'`),
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
]);
return {
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
memberCount: members.length,
claimedRewards: (rewards as any[]).filter((r: any) => r.claimed).length,
totalRewards: rewards.length,
};
}));
const totalFams = fams.length;
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
return { fams: famsWithStats, totalFams, totalMembers, totalRewards };
}
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
let { data } = $props();
</script>
<h1>Super Admin</h1>
<div class="stats">
<div class="card"><strong>{data.totalFams}</strong> Families</div>
<div class="card"><strong>{data.totalMembers}</strong> Members</div>
<div class="card"><strong>{data.totalRewards}</strong> Rewards</div>
</div>
<h2>Families</h2>
<table>
<thead><tr><th>Name</th><th>Slug</th><th>Members</th><th>Rewards</th><th>Invite Code</th></tr></thead>
<tbody>
{#each data.fams as fam}
<tr>
<td><a href="/{fam.slug}/admin">{fam.name}</a></td>
<td>{fam.slug}</td>
<td>{fam.memberCount}</td>
<td>{fam.claimedRewards}/{fam.totalRewards}</td>
<td><code>{fam.inviteCode}</code></td>
</tr>
{/each}
</tbody>
</table>
<style>
.stats { display: flex; gap: 1rem; margin: 1rem 0; }
.card { padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; flex: 1; text-align: center; }
.card strong { font-size: 1.5rem; display: block; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { padding: 0.5rem; border: 1px solid #e5e7eb; text-align: left; }
th { background: #f9fafb; font-weight: 600; }
code { background: #eee; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
</style>
@@ -0,0 +1,24 @@
import { fail, redirect } from '@sveltejs/kit';
import { joinMember, setDeviceTokenCookie } from '$lib/server/auth';
export const actions = {
default: async (event) => {
const code = event.params.code;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) {
return fail(400, { error: 'Name is required' });
}
const deviceToken = crypto.randomUUID();
try {
const result = await joinMember(code, name, deviceToken);
setDeviceTokenCookie(event, deviceToken);
throw redirect(303, `/${result.famSlug}/${result.name}`);
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' });
}
},
};
@@ -0,0 +1,29 @@
<script lang="ts">
let { form } = $props();
let name = $state('');
</script>
<h1>Join family</h1>
<p>Enter your name to join. It must match a member slot created by your admin.</p>
<form method="POST">
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<label>
Your name
<input name="name" bind:value={name} required placeholder="Enter your exact name" />
</label>
<button type="submit">Join</button>
</form>
<style>
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 2rem auto; }
label { display: flex; flex-direction: column; gap: 0.25rem; }
input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 0.75rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
</style>
@@ -0,0 +1,24 @@
import { fail, redirect } from '@sveltejs/kit';
import { setDeviceTokenCookie } from '$lib/server/auth';
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export const actions = {
default: async (event) => {
const code = event.params.code;
const name = event.params.member;
const res = await fetch(`${HONO_URL}/api/members/direct-join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode: code, name }),
});
const data = await res.json();
if (!res.ok) return fail(400, { error: data.error || 'Join failed' });
setDeviceTokenCookie(event, data.deviceToken);
throw redirect(303, `/${data.famSlug}/${data.name}`);
},
};
@@ -0,0 +1,24 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
let { form } = $props();
let memberName = $derived(page.params.member);
</script>
<h1>Join family</h1>
{#if form?.error}
<p class="error">{form.error}</p>
{:else}
<p>Click below to join as <strong>{memberName}</strong></p>
{/if}
<form method="POST" use:enhance>
<button type="submit">Join</button>
</form>
<style>
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
button { padding: 0.75rem 1.5rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
</style>
+33
View File
@@ -0,0 +1,33 @@
import { fail, redirect } from '@sveltejs/kit';
import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
export const actions = {
default: async (event) => {
const fd = await event.request.formData();
const email = fd.get('email') as string;
const password = fd.get('password') as string;
if (!email || !password) {
return fail(400, { error: 'Email and password required', email });
}
let result: any;
try {
result = await login(email, password);
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email });
}
setSessionCookie(event, {
famId: result.famId,
userId: result.userId,
famSlug: result.famSlug,
memberName: result.memberName,
role: result.role,
});
setPbTokenCookie(event, result.token);
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
},
};
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
let { form } = $props();
let email = $state('');
let password = $state('');
</script>
<h1>Log in</h1>
<form method="POST">
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<label>
Email
<input type="email" name="email" bind:value={email} required />
</label>
<label>
Password
<input type="password" name="password" bind:value={password} required />
</label>
<button type="submit">Log in</button>
</form>
<p>Don't have a family yet? <a href="/signup">Create one</a></p>
<style>
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 2rem auto; }
label { display: flex; flex-direction: column; gap: 0.25rem; }
input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 0.75rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
</style>
@@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
import { clearSession } from '$lib/server/auth';
export function load(event) {
clearSession(event);
throw redirect(303, '/');
}
export const actions = {
default: (event) => {
clearSession(event);
throw redirect(303, '/');
},
};
@@ -0,0 +1,35 @@
import { fail, redirect } from '@sveltejs/kit';
import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
export const actions = {
default: async (event) => {
const fd = await event.request.formData();
const email = fd.get('email') as string;
const password = fd.get('password') as string;
const famName = fd.get('famName') as string;
const parentName = fd.get('parentName') as string;
if (!email || !password || !famName) {
return fail(400, { error: 'All fields required', email, famName });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', email, famName });
}
try {
const result = await signup(email, password, famName, parentName || email.split('@')[0]);
setSessionCookie(event, {
famId: result.famId,
userId: result.userId,
famSlug: result.famSlug,
memberName: result.memberName,
role: result.role,
});
setPbTokenCookie(event, result.token);
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
} catch (e) {
if (e instanceof redirect) throw e;
return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName });
}
},
};
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
let { form } = $props();
let email = $state('');
let password = $state('');
let famName = $state('');
let parentName = $state('');
</script>
<h1>Create your family</h1>
<form method="POST">
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<label>
Family name
<input name="famName" bind:value={famName} required />
</label>
<label>
Your name (parent)
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
</label>
<label>
Email
<input type="email" name="email" bind:value={email} required />
</label>
<label>
Password
<input type="password" name="password" bind:value={password} minlength={8} required />
</label>
<button type="submit">Create family</button>
</form>
<p>Already have an account? <a href="/login">Log in</a></p>
<style>
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 2rem auto; }
label { display: flex; flex-direction: column; gap: 0.25rem; }
input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 0.75rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
</style>
+1
View File
@@ -0,0 +1 @@
/dist
-2
View File
@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=index.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
-35
View File
@@ -1,35 +0,0 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8090';
const PB_EMAIL = process.env.PB_SUPERUSER_EMAIL || '';
const PB_PASSWORD = process.env.PB_SUPERUSER_PASSWORD || '';
const DEBUG_RECORD_ID = process.env.DEBUG_RECORD_ID || '';
const app = new Hono();
app.get('/api/health', (c) => c.json({ status: 'ok' }));
app.post('/api/debug/increment-hono', async (c) => {
const auth = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
});
const { token } = await auth.json();
const record = await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await record.json();
const current = data.hono_count || 0;
await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ hono_count: current + 1 })
});
return c.json({ ok: true });
});
const port = parseInt(process.env.PROXY_PORT || '3456', 10);
serve({ fetch: app.fetch, port }, (info) => {
console.log(`Hono proxy listening on http://localhost:${info.port}`);
});
//# sourceMappingURL=index.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,uBAAuB,CAAC;AAC7D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC;AACtD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC;AAC5D,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,CAAC;AAE1D,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;AAEvB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AAExD,GAAG,CAAC,IAAI,CAAC,2BAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;IACjD,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,iDAAiD,EAAE;QACpF,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;KACnE,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;IAEpC,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACxF,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE;KAC7C,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACjC,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;IAErC,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACzE,MAAM,EAAE,OAAO;QACf,OAAO,EAAE;YACR,cAAc,EAAE,kBAAkB;YAClC,aAAa,EAAE,UAAU,KAAK,EAAE;SAChC;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,EAAE,CAAC;KACjD,CAAC,CAAC;IAEH,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7B,CAAC,CAAC,CAAC;AAEH,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;AAE5D,KAAK,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,EAAE;IAC1C,OAAO,CAAC,GAAG,CAAC,4CAA4C,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;AACtE,CAAC,CAAC,CAAC"}
+2 -1
View File
@@ -5,7 +5,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.0",
+314
View File
@@ -0,0 +1,314 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
interface FieldDef {
name: string
type: string
required?: boolean
unique?: boolean
max?: number
min?: number
values?: string[]
maxSelect?: number
collectionId?: string
cascadeDelete?: boolean
}
interface CollectionDef {
name: string
type: string
fields: FieldDef[]
listRule?: string | null
viewRule?: string | null
createRule?: string | null
updateRule?: string | null
deleteRule?: string | null
}
async function getSuperadminToken(): Promise<string> {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(`Auth failed: ${JSON.stringify(data)}`);
return data.token;
}
async function createCollection(
token: string,
col: CollectionDef,
): Promise<string | null> {
const existing = await fetch(
`${PB_ENDPOINT}/api/collections?filter=name='${col.name}'`,
{ headers: { Authorization: `Bearer ${token}` } },
);
const existingData = await existing.json();
if (existingData?.items?.length > 0) {
console.log(` ↳ Already exists: ${col.name}`);
return existingData.items[0].id;
}
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(col),
});
const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created: ${col.name}`);
return data.id;
}
function text(name: string, required = false): FieldDef {
return { name, type: "text", required };
}
function uniqueText(name: string): FieldDef {
return { name, type: "text", required: true, unique: true };
}
function number(name: string, required = false): FieldDef {
return { name, type: "number", required };
}
function bool(name: string): FieldDef {
return { name, type: "bool" };
}
function date(name: string): FieldDef {
return { name, type: "date" };
}
function jsonField(name: string): FieldDef {
return { name, type: "json" };
}
function select(name: string, values: string[], required = false): FieldDef {
return { name, type: "select", required, values, maxSelect: 1 };
}
function rel(name: string, collectionId: string, required = false): FieldDef {
return {
name,
type: "relation",
required,
collectionId,
maxSelect: 1,
cascadeDelete: false,
};
}
async function main() {
console.log("Connecting to PB at", PB_ENDPOINT);
const token = await getSuperadminToken();
console.log("Authenticated as superadmin\n");
const ids: Record<string, string> = {};
// ── Pass 1: Independent collections ──
console.log("--- Pass 1: Independent collections ---");
ids.fams = await createCollection(token, {
name: "fams",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
text("name", true),
uniqueText("slug"),
text("inviteCode"),
text("stripeCustomerId"),
jsonField("featureFlags"),
],
});
// ── Pass 2: Collections that reference fams ──
console.log("\n--- Pass 2: fam-scoped collections ---");
ids.settings = await createCollection(token, {
name: "settings",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
text("webhookUrl"),
],
});
ids.members = await createCollection(token, {
name: "members",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
text("name", true),
text("color"),
text("deviceToken"),
text("deviceTokenHint"),
],
});
ids.chore_templates = await createCollection(token, {
name: "chore_templates",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
text("name", true),
text("description"),
select("defaultFrequency", ["daily", "weekly"], true),
select("defaultType", ["points", "money"], true),
number("defaultValue", true),
],
});
ids.fam_admins = await createCollection(token, {
name: "fam_admins",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
text("userId", true),
text("email", true),
text("name"),
text("color"),
],
});
ids.bonus_configs = await createCollection(token, {
name: "bonus_configs",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
text("name", true),
text("description"),
select("target", ["individual", "competitive"], true),
select("type", ["threshold", "count", "manual"], true),
select("occurrence", ["recurring", "once"], true),
select("rewardType", ["points", "cash", "prize"], true),
text("rewardValue", true),
number("criteriaValue"),
select("period", ["weekly", "monthly"]),
select("status", ["active", "archived"], true),
],
});
// ── Pass 3: Collections with member/template deps ──
console.log("\n--- Pass 3: Nested collections ---");
ids.weekly_history = await createCollection(token, {
name: "weekly_history",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
rel("memberId", ids.members!, true),
date("weekStart"),
number("pointsEarned"),
number("moneyEarned"),
number("choresCompleted"),
number("bonusEarned"),
],
});
ids.rewards = await createCollection(token, {
name: "rewards",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
rel("memberId", ids.members!, true),
rel("bonusConfigId", ids.bonus_configs!),
text("label", true),
number("value", true),
select("rewardType", ["cash", "prize", "points"], true),
bool("claimed"),
date("claimedAt"),
text("date"),
],
});
ids.assigned_chores = await createCollection(token, {
name: "assigned_chores",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
rel("memberId", ids.members!, true),
rel("templateId", ids.chore_templates!, true),
select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money"], true),
number("value", true),
text("customName"),
],
});
ids.completions = await createCollection(token, {
name: "completions",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
rel("memberId", ids.members!, true),
rel("assignedChoreId", ids.assigned_chores!, true),
date("date"),
],
});
console.log("\n✅ All collections created successfully");
console.log("Collection IDs:", ids);
}
main().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});
+99
View File
@@ -0,0 +1,99 @@
const HONO = "http://192.168.1.225:3456";
async function main() {
// Signup
const signup = await fetch(`${HONO}/api/admin/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "admin2@test.com", password: "password1234", famName: "Admin Test" }),
});
const s = await signup.json();
console.log("Signup:", s.famId, s.famSlug);
const famId = s.famId;
// Test admin authenticated calls
const headers = {
"x-session-famid": famId,
"x-session-role": "admin",
"x-session-userid": s.userId,
"Content-Type": "application/json",
};
// Create a chore template
console.log("\nCreate template...");
const tmpl = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, {
method: "POST", headers, body: JSON.stringify({
name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 10,
}),
});
const t = await tmpl.json();
console.log("Template:", t.id, t.name);
// List templates
console.log("\nList templates...");
const list = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { headers });
console.log("Templates:", (await list.json()).length);
// Create a member
console.log("\nCreate member...");
const mem = await fetch(`${HONO}/api/admin/${famId}/members`, {
method: "POST", headers, body: JSON.stringify({ name: "Kid", color: "#6366f1" }),
});
const m = await mem.json();
console.log("Member:", m.id, m.name);
// Assign chore
console.log("\nAssign chore...");
const assign = await fetch(`${HONO}/api/admin/${famId}/assigned-chores`, {
method: "POST", headers, body: JSON.stringify({
memberId: m.id, templateId: t.id, frequency: "daily", type: "points", value: 10,
}),
});
const a = await assign.json();
console.log("Assigned:", a.id);
// Test device token auth
console.log("\nTest completion toggle with device token...");
const devHeaders = {
"x-device-token": "dev-token-test",
"x-device-famid": famId,
"Content-Type": "application/json",
};
// First need to join with this device token
const join = await fetch(`${HONO}/api/members/join`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inviteCode: s.famSlug, ...(await (await fetch(`${HONO}/api/admin/${famId}/members`, { headers })).json()).length > 1 ? {} : { name: "Test", deviceToken: "dev-token-test" } }),
});
// Actually, just use the member we already created but we can't use device token with it since it has no token
// Let's join a new one
console.log("Joining with device token...");
const j = await fetch(`${HONO}/api/members/join`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inviteCode: "TEST", name: "Test Kid", deviceToken: "dev-token-test" }),
});
const joinData = await j.json();
console.log("Join result:", JSON.stringify(joinData));
if (joinData.famId) {
// Toggle completion
console.log("\nToggle completion...");
const toggle = await fetch(`${HONO}/api/completions/toggle`, {
method: "POST",
headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" },
body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }),
});
console.log("Toggle:", await toggle.json());
// Toggle again (should undo)
const toggle2 = await fetch(`${HONO}/api/completions/toggle`, {
method: "POST",
headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" },
body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }),
});
console.log("Toggle undo:", await toggle2.json());
}
console.log("\n✅ All admin tests passed");
}
main().catch(console.error);
+63
View File
@@ -0,0 +1,63 @@
const HONO = "http://192.168.1.225:3456";
async function main() {
// 1. Signup
console.log("=== Signup ===");
const signup = await fetch(`${HONO}/api/admin/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@fam.com", password: "password123", famName: "Test Fam" }),
});
const signupData = await signup.json();
console.log("Signup:", JSON.stringify(signupData, null, 2));
// 2. Login
console.log("\n=== Login ===");
const login = await fetch(`${HONO}/api/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@fam.com", password: "password123" }),
});
const loginData = await login.json();
console.log("Login:", JSON.stringify(loginData, null, 2));
// 3. Get invite code from fams directly via PB
const pbTokenRes = await fetch("http://192.168.1.225:8090/api/collections/_superusers/auth-with-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: "debug@famchamp.dev", password: "debug123" }),
});
const pbTokenData = await pbTokenRes.json();
const pbSuperToken = pbTokenData.token;
const famsRes = await fetch("http://192.168.1.225:8090/api/collections/fams/records?sort=-created", {
headers: { Authorization: `Bearer ${pbSuperToken}` },
});
const famsData = await famsRes.json();
const fam = famsData.items[0];
console.log("\n=== Fam ===");
console.log("Fam:", JSON.stringify(fam, null, 2));
console.log("Invite code:", fam.inviteCode);
// 4. Join as member
console.log("\n=== Join ===");
const join = await fetch(`${HONO}/api/members/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inviteCode: fam.inviteCode, name: "Kid", deviceToken: "dev-token-xyz" }),
});
const joinData = await join.json();
console.log("Join:", JSON.stringify(joinData, null, 2));
// 5. Verify member
console.log("\n=== Verify ===");
const verify = await fetch(`${HONO}/api/members/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ famId: fam.id, deviceToken: "dev-token-xyz" }),
});
const verifyData = await verify.json();
console.log("Verify:", JSON.stringify(verifyData, null, 2));
}
main().catch(console.error);
+1071 -41
View File
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
let token: string | null = null;
async function auth(): Promise<string> {
if (token) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
});
const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token;
return token!;
}
async function getCollection(name: string): Promise<any | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
return data?.items?.[0] || null;
}
async function createCollection(col: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(col),
});
const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created collection: ${col.name}`);
}
async function updateCollection(id: string, col: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(col),
});
const data = await res.json();
if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Updated collection: ${col.name || id}`);
}
export async function migrate(): Promise<void> {
console.log("[migrate] Checking PB collection schemas...");
// ── 1. Create bonus_configs if missing ──
const existing = await getCollection("bonus_configs");
if (!existing) {
// Need to get fams collection ID first
const famsCol = await getCollection("fams");
if (!famsCol) throw new Error("fams collection not found");
const famsId = famsCol.id;
console.log("[migrate] Creating bonus_configs collection...");
await createCollection({
name: "bonus_configs",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false },
{ name: "name", type: "text", required: true },
{ name: "description", type: "text", required: false },
{ name: "target", type: "select", required: true, values: ["individual", "competitive", "collaborative"], maxSelect: 1 },
{ name: "type", type: "select", required: true, values: ["threshold", "count", "manual"], maxSelect: 1 },
{ name: "occurrence", type: "select", required: true, values: ["recurring", "once"], maxSelect: 1 },
{ name: "rewardType", type: "select", required: true, values: ["points", "cash", "prize"], maxSelect: 1 },
{ name: "rewardValue", type: "text", required: true },
{ name: "criteriaValue", type: "number", required: false },
{ name: "period", type: "select", required: false, values: ["schedule", "weekly", "monthly"], maxSelect: 1 },
{ name: "status", type: "select", required: true, values: ["active", "archived"], maxSelect: 1 },
],
});
} else {
console.log(` ↳ bonus_configs already exists`);
const targetField = existing.fields.find((f: any) => f.name === "target");
const periodField = existing.fields.find((f: any) => f.name === "period");
const needTargetUpdate = targetField && !targetField.values.includes("collaborative");
const needPeriodUpdate = periodField && !periodField.values.includes("schedule");
if (needTargetUpdate || needPeriodUpdate) {
console.log("[migrate] Updating bonus_configs fields...");
if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"];
if (needPeriodUpdate) periodField.values = ["schedule", "weekly", "monthly"];
await updateCollection(existing.id, {
name: "bonus_configs",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: existing.fields,
});
}
}
// ── 2. Update rewards collection ──
const rewardsCol = await getCollection("rewards");
if (rewardsCol) {
const fieldNames = rewardsCol.fields.map((f: any) => f.name);
const needsUpdate =
!fieldNames.includes("date") ||
fieldNames.includes("autoClaimed") ||
fieldNames.includes("weekStart") ||
fieldNames.includes("month");
if (needsUpdate) {
console.log("[migrate] Updating rewards collection schema...");
const bonusConfigsCol = await getCollection("bonus_configs");
const keepFields = rewardsCol.fields.filter((f: any) =>
["famId", "memberId", "label", "value", "claimed", "claimedAt", "rewardType", "bonusConfigId", "created", "updated", "id"].includes(f.name)
);
const newFields = [
...keepFields,
...(bonusConfigsCol && !fieldNames.includes("bonusConfigId")
? [{ name: "bonusConfigId", type: "relation", required: false, collectionId: bonusConfigsCol.id, maxSelect: 1, cascadeDelete: false }]
: []),
...(fieldNames.includes("rewardType") ? [] : [{ name: "rewardType", type: "select", required: true, values: ["cash", "prize", "points"], maxSelect: 1 }]),
...(fieldNames.includes("claimedAt") ? [] : [{ name: "claimedAt", type: "date", required: false }]),
{ name: "date", type: "text", required: false },
];
await updateCollection(rewardsCol.id, {
name: "rewards",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: newFields,
});
// Backfill existing rewards
const today = new Date().toISOString().slice(0, 10);
const backfillRes = await fetch(
`${PB_ENDPOINT}/api/collections/rewards/records?perPage=200`,
{ headers: { Authorization: `Bearer ${await auth()}` } },
);
const backfillData = await backfillRes.json();
if (backfillData?.items) {
for (const r of backfillData.items) {
const t2 = await auth();
const patches: Record<string, any> = {};
if (!r.date) {
patches.date = r.claimedAt?.slice(0, 10) || today;
}
if (r.rewardType === "points" && !r.claimed) {
patches.claimed = true;
patches.claimedAt = new Date().toISOString();
}
if (Object.keys(patches).length > 0) {
await fetch(`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t2}` },
body: JSON.stringify(patches),
});
}
}
console.log(` ✓ Backfilled ${backfillData.items.length} rewards`);
}
} else {
console.log(` ↳ rewards schema is current`);
}
} else {
console.log(` ↳ rewards collection not found (will be created by seed)`);
}
// ── 3. Update settings collection (drop old fields) ──
const settingsCol = await getCollection("settings");
if (settingsCol) {
const fieldNames = settingsCol.fields.map((f: any) => f.name);
if (fieldNames.includes("pointsThreshold") || fieldNames.includes("weeklyBonus")) {
console.log("[migrate] Updating settings collection (dropping old fields)...");
const keepFields = settingsCol.fields.filter((f: any) =>
["famId", "webhookUrl", "created", "updated", "id"].includes(f.name)
);
await updateCollection(settingsCol.id, {
name: "settings",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: keepFields,
});
} else {
console.log(` ↳ settings schema is current`);
}
}
// ── 4. Create notifications collection if missing ──
const notifCol = await getCollection("notifications");
if (!notifCol) {
const famsCol = await getCollection("fams");
if (!famsCol) throw new Error("fams collection not found");
const membersCol = await getCollection("members");
if (!membersCol) throw new Error("members collection not found");
console.log("[migrate] Creating notifications collection...");
await createCollection({
name: "notifications",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "famId", type: "relation", required: true, collectionId: famsCol.id, maxSelect: 1, cascadeDelete: false },
{ name: "memberId", type: "relation", required: true, collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false },
{ name: "message", type: "text", required: true },
{ name: "read", type: "bool", required: false },
],
});
} else {
console.log(` ↳ notifications already exists`);
}
// ── 5. Add payday field to fams if missing ──
const famsCol = await getCollection("fams");
if (famsCol) {
const hasPayday = famsCol.fields.some((f: any) => f.name === "payday");
if (!hasPayday) {
console.log("[migrate] Adding payday field to fams...");
const paydayField = { name: "payday", type: "number", required: false, min: 0, max: 6 };
famsCol.fields.push(paydayField);
await updateCollection(famsCol.id, {
name: "fams",
type: "base",
listRule: famsCol.listRule,
viewRule: famsCol.viewRule,
createRule: famsCol.createRule,
updateRule: famsCol.updateRule,
deleteRule: famsCol.deleteRule,
fields: famsCol.fields,
});
} else {
console.log(` ↳ fams.payday already exists`);
}
}
// ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ──
// PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date`
// and just normalise existing records. The frontend reads with .slice(0, 10) either way.
try {
const completionsCol = await getCollection("completions");
if (completionsCol) {
const t = await auth();
const all = await fetch(`${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await all.json();
if (data?.items?.length) {
let backfilled = 0;
for (const rec of data.items) {
const plain = rec.date?.slice(0, 10);
if (plain && plain !== rec.date) {
await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ date: plain }),
});
backfilled++;
}
}
if (backfilled > 0) console.log(` ✓ Backfilled ${backfilled} completion dates`);
else console.log(` ↳ completions.date already normalised`);
}
}
} catch (e) {
console.log(` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`);
}
// ── 7. Add memberId field to bonus_configs ──
const bonusConfigsCol = await getCollection("bonus_configs");
if (bonusConfigsCol) {
const hasMemberId = bonusConfigsCol.fields.some((f: any) => f.name === "memberId");
if (!hasMemberId) {
const membersCol = await getCollection("members");
if (membersCol) {
console.log("[migrate] Adding memberId field to bonus_configs...");
bonusConfigsCol.fields.push({
name: "memberId", type: "relation", required: false,
collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false,
});
await updateCollection(bonusConfigsCol.id, {
name: "bonus_configs",
type: "base",
listRule: bonusConfigsCol.listRule,
viewRule: bonusConfigsCol.viewRule,
createRule: bonusConfigsCol.createRule,
updateRule: bonusConfigsCol.updateRule,
deleteRule: bonusConfigsCol.deleteRule,
fields: bonusConfigsCol.fields,
});
}
} else {
console.log(` ↳ bonus_configs.memberId already exists`);
}
}
// ── 8. Add phase field to bonus_configs ──
if (bonusConfigsCol) {
const hasPhase = bonusConfigsCol.fields.some((f: any) => f.name === "phase");
if (!hasPhase) {
console.log("[migrate] Adding phase field to bonus_configs...");
bonusConfigsCol.fields.push({
name: "phase", type: "select", required: true,
values: ["template", "ready", "active", "completed"], maxSelect: 1,
});
await updateCollection(bonusConfigsCol.id, {
name: "bonus_configs",
type: "base",
listRule: bonusConfigsCol.listRule,
viewRule: bonusConfigsCol.viewRule,
createRule: bonusConfigsCol.createRule,
updateRule: bonusConfigsCol.updateRule,
deleteRule: bonusConfigsCol.deleteRule,
fields: bonusConfigsCol.fields,
});
// Backfill existing records → phase = "active"
const t = await auth();
const all = await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await all.json();
if (data?.items?.length) {
for (const rec of data.items) {
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ phase: "active" }),
});
}
console.log(` ✓ Backfilled ${data.items.length} bonus_configs → phase=active`);
}
} else {
console.log(` ↳ bonus_configs.phase already exists`);
}
}
// ── 9. Add role + userId fields to members ──
const membersCol = await getCollection("members");
if (membersCol) {
const hasRole = membersCol.fields.some((f: any) => f.name === "role");
const hasUserId = membersCol.fields.some((f: any) => f.name === "userId");
if (!hasRole || !hasUserId) {
console.log("[migrate] Adding role/userId fields to members...");
if (!hasRole) membersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 });
if (!hasUserId) membersCol.fields.push({ name: "userId", type: "text", required: false });
await updateCollection(membersCol.id, {
name: "members",
type: "base",
listRule: membersCol.listRule,
viewRule: membersCol.viewRule,
createRule: membersCol.createRule,
updateRule: membersCol.updateRule,
deleteRule: membersCol.deleteRule,
fields: membersCol.fields,
});
} else {
console.log(` ↳ members.role/userId already exists`);
}
}
// ── 10. Add email field to members ──
if (membersCol) {
const hasEmail = membersCol.fields.some((f: any) => f.name === "email");
if (!hasEmail) {
console.log("[migrate] Adding email field to members...");
membersCol.fields.push({ name: "email", type: "text", required: false });
await updateCollection(membersCol.id, {
name: "members",
type: "base",
listRule: membersCol.listRule,
viewRule: membersCol.viewRule,
createRule: membersCol.createRule,
updateRule: membersCol.updateRule,
deleteRule: membersCol.deleteRule,
fields: membersCol.fields,
});
} else {
console.log(` ↳ members.email already exists`);
}
}
// ── 11. Backfill userId on existing member records ──
if (membersCol) {
try {
const t = await auth();
const allMembers = await fetch(`${PB_ENDPOINT}/api/collections/members/records?perPage=1000`, {
headers: { Authorization: `Bearer ${t}` },
});
const membersData = await allMembers.json();
const needBackfill = (membersData?.items || []).filter((m: any) => !m.userId);
if (needBackfill.length > 0) {
console.log(`[migrate] Backfilling userId for ${needBackfill.length} members...`);
const adminsRes = await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, {
headers: { Authorization: `Bearer ${t}` },
});
const adminsData = await adminsRes.json();
const adminsByFam = new Map<string, any[]>();
for (const a of (adminsData?.items || [])) {
const list = adminsByFam.get(a.famId) || [];
list.push(a);
adminsByFam.set(a.famId, list);
}
let count = 0;
for (const m of needBackfill) {
const admins = adminsByFam.get(m.famId) || [];
if (admins.length === 1) {
await fetch(`${PB_ENDPOINT}/api/collections/members/records/${m.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ userId: admins[0].userId }),
});
count++;
}
}
if (count > 0) console.log(` ✓ Backfilled ${count} member userIds`);
if (count < needBackfill.length) console.log(` ↳ Skipped ${needBackfill.length - count} members (no unique fam_admin match)`);
} else {
console.log(` ↳ members.userId already backfilled`);
}
} catch (e) {
console.log(` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`);
}
}
// ── 12. Drop userId field from members ──
if (membersCol) {
const hasUserId = membersCol.fields.some((f: any) => f.name === "userId");
if (hasUserId) {
console.log("[migrate] Dropping userId field from members...");
membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "userId");
await updateCollection(membersCol.id, {
name: "members",
type: "base",
listRule: membersCol.listRule,
viewRule: membersCol.viewRule,
createRule: membersCol.createRule,
updateRule: membersCol.updateRule,
deleteRule: membersCol.deleteRule,
fields: membersCol.fields,
});
} else {
console.log(` ↳ members.userId already removed`);
}
}
// ── 13. Add name + color fields to fam_admins ──
const adminsCol = await getCollection("fam_admins");
if (adminsCol) {
const hasName = adminsCol.fields.some((f: any) => f.name === "name");
const hasColor = adminsCol.fields.some((f: any) => f.name === "color");
if (!hasName || !hasColor) {
console.log("[migrate] Adding name/color to fam_admins...");
if (!hasName) adminsCol.fields.push({ name: "name", type: "text", required: false });
if (!hasColor) adminsCol.fields.push({ name: "color", type: "text", required: false });
await updateCollection(adminsCol.id, {
name: "fam_admins",
type: "base",
listRule: adminsCol.listRule || "",
viewRule: adminsCol.viewRule || "",
createRule: adminsCol.createRule,
updateRule: adminsCol.updateRule,
deleteRule: adminsCol.deleteRule,
fields: adminsCol.fields,
});
// Backfill name from email prefix
const t = await auth();
const all = await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await all.json();
for (const a of (data?.items || [])) {
const patch: Record<string, string> = {};
if (!a.name) patch.name = (a.email || "admin").split("@")[0];
if (!a.color) patch.color = "#6366f1";
if (Object.keys(patch).length) {
await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(patch),
});
}
}
console.log(` ✓ Backfilled name/color for ${(data?.items || []).length} admins`);
} else {
console.log(` ↳ fam_admins.name/color already exists`);
}
}
// ── 14. Drop role field from members ──
if (membersCol) {
const hasRole = membersCol.fields.some((f: any) => f.name === "role");
if (hasRole) {
console.log("[migrate] Dropping role field from members...");
membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "role");
await updateCollection(membersCol.id, {
name: "members",
type: "base",
listRule: membersCol.listRule,
viewRule: membersCol.viewRule,
createRule: membersCol.createRule,
updateRule: membersCol.updateRule,
deleteRule: membersCol.deleteRule,
fields: membersCol.fields,
});
} else {
console.log(` ↳ members.role already removed`);
}
}
// ── 15. Drop email field from members ──
if (membersCol) {
const hasEmail = membersCol.fields.some((f: any) => f.name === "email");
if (hasEmail) {
console.log("[migrate] Dropping email field from members...");
membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "email");
await updateCollection(membersCol.id, {
name: "members",
type: "base",
listRule: membersCol.listRule,
viewRule: membersCol.viewRule,
createRule: membersCol.createRule,
updateRule: membersCol.updateRule,
deleteRule: membersCol.deleteRule,
fields: membersCol.fields,
});
} else {
console.log(` ↳ members.email already removed`);
}
}
console.log("[migrate] Done");
}
+94
View File
@@ -0,0 +1,94 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
let adminToken: string | null = null;
let tokenExpiry = 0;
async function ensureToken(): Promise<string> {
if (adminToken && Date.now() < tokenExpiry) return adminToken;
const res = await fetch(
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
adminToken = data.token;
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
return adminToken!;
}
async function request(
method: string,
path: string,
body?: unknown,
): Promise<Response> {
const token = await ensureToken();
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (body) headers["Content-Type"] = "application/json";
return fetch(`${PB_ENDPOINT}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
export const pb = {
async create(collection: string, data: Record<string, unknown>) {
const res = await request("POST", `/api/collections/${collection}/records`, data);
const json = await res.json();
if (!res.ok) throw new Error(`PB create ${collection}: ${JSON.stringify(json)}`);
return json;
},
async update(collection: string, id: string, data: Record<string, unknown>) {
const res = await request("PATCH", `/api/collections/${collection}/records/${id}`, data);
const json = await res.json();
if (!res.ok) throw new Error(`PB update ${collection}: ${JSON.stringify(json)}`);
return json;
},
async delete(collection: string, id: string) {
const res = await request("DELETE", `/api/collections/${collection}/records/${id}`);
if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status}`);
},
async getList(collection: string, filter = "") {
const params = new URLSearchParams();
if (filter) params.set("filter", filter);
params.set("perPage", "200");
const res = await request("GET", `/api/collections/${collection}/records?${params}`);
const json = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`);
return json;
},
async authWithPassword(identity: string, password: string) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/users/auth-with-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
},
);
const json = await res.json();
if (!res.ok) throw new Error(`PB auth: ${JSON.stringify(json)}`);
return json;
},
async createUser(email: string, password: string) {
return pb.create("users", {
email,
password,
passwordConfirm: password,
emailVisibility: false,
});
},
};