Compare commits

...

9 Commits

Author SHA1 Message Date
JCEEE 2fbf249bfb 1.9.1 2026-09-03 12:17:04 +01:00
JCEEE b13de60b52 add some sugar to homepage 2026-09-03 12:16:50 +01:00
JCEEE ed31a6e25d 1.9.0 2026-09-03 11:51:19 +01:00
JCEEE 68a0851467 update footer | add shared chores | expire todos if not completed 2026-09-03 11:50:51 +01:00
JCEEE 8cec99715a fix login confusion and enforce reactive subscription after login 2026-09-03 09:31:40 +01:00
JCEEE 1360799b60 Add FamDone Brand component and add rewards count system 2026-09-02 13:32:11 +01:00
JCEEE eaa5104817 1.8.8 2026-09-02 11:50:57 +01:00
JCEEE f288df5b9c Add FamDone Brand component 2026-09-02 11:50:51 +01:00
JCEEE 1d4d6be20b new favicon 2026-09-02 11:35:56 +01:00
40 changed files with 1738 additions and 736 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
--- ---
# FamChore v2 — AI Agent Reference # FamDone v2 — AI Agent Reference
## Stack ## Stack
+1 -1
View File
@@ -1,4 +1,4 @@
# FamChore v2 — Architecture & Developer Reference # FamDone v2 — Architecture & Developer Reference
## Overview ## Overview
+7 -2
View File
@@ -1,4 +1,4 @@
# FamChore v2 — Development Memory # FamDone v2 — Development Memory
## 2026-08-17 — Schema collapse to single source of truth (Option 1) ## 2026-08-17 — Schema collapse to single source of truth (Option 1)
@@ -34,7 +34,7 @@
``` ```
### Sidebar (collapsible to mini-mode) ### Sidebar (collapsible to mini-mode)
- Header: app name (FamChore) - Header: app name (FamDone)
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses - Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
- Member CTAs: Dashboard, Preferences - Member CTAs: Dashboard, Preferences
- Footer: family name, Settings (admin only), Log out - Footer: family name, Settings (admin only), Log out
@@ -362,3 +362,8 @@
- **`isDummyStripe` removed:** real test keys made every dummy branch dead code — and the webhook's `|| !signature` fallback accepted UNSIGNED events (forgeable access grants). Signature is now mandatory (400 without it); settings' simulated endSubscription/portal branches deleted. Dev verification stays via stripe-cli signed events (`pnpm stripe:listen`). - **`isDummyStripe` removed:** real test keys made every dummy branch dead code — and the webhook's `|| !signature` fallback accepted UNSIGNED events (forgeable access grants). Signature is now mandatory (400 without it); settings' simulated endSubscription/portal branches deleted. Dev verification stays via stripe-cli signed events (`pnpm stripe:listen`).
- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login. - **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login.
- **use:enhance redirect gotcha (session-wide lesson):** action-thrown redirects surface as `result.type === 'redirect'` in the RESOLVE callback — handlers checking only `'success'` silently drop them (bit /admin login, pricing choose, and settings billingPortal). Shared fix: `lib/forms.ts` → `handleResult(handler?)` factory follows redirects via `goto`, delegates the rest to the handler or default `update()`. Use it for any form whose action can throw a redirect. Also: enhance is two-stage — `{result}` only exists in the resolve fn, not the submit params (was mis-handled in pricing/signup handlers). - **use:enhance redirect gotcha (session-wide lesson):** action-thrown redirects surface as `result.type === 'redirect'` in the RESOLVE callback — handlers checking only `'success'` silently drop them (bit /admin login, pricing choose, and settings billingPortal). Shared fix: `lib/forms.ts` → `handleResult(handler?)` factory follows redirects via `goto`, delegates the rest to the handler or default `update()`. Use it for any form whose action can throw a redirect. Also: enhance is two-stage — `{result}` only exists in the resolve fn, not the submit params (was mis-handled in pricing/signup handlers).
### 2026-09-02 — Count-type reward: Target Chore + TODO (period semantics)
- **Feature:** Count-type rewards now support pinning to a single assigned chore (`bonus_configs.targetChoreId`). On the fam-admin Rewards modal (Step 2), when Type = "Count - (chores)" a **Target Chore** dropdown appears after the Target Value field — options are the selected member's assigned (non-todo) chores, default "All Chores". Evaluation (`bonuses.evaluateFam`) + display (`bonuses.progress`, dashboard `thresholdGoals`) now filter Count progress to only that chore's completions when set. Platform-level template form only needed the relabeled "Count - (chores)" — no target chore at template level. Pocket-money checkbox removed from the platform template form (only the auto-created per-child droplet needs it).
- **TODO (logic, not yet fixed):** Count rewards with a `period` (e.g. weekly) reset + re-earn each period like a points/cash threshold. Once a Count reward is pinned to a target (or all chores), the intended semantics are ambiguous: should it be **continuous** (unlimited, measured once since startDate until reached) or **limited to the period window** (re-earn each week)? Currently it follows the periodic-reset behavior. Decide whether Count rewards should ignore `period` (behave as standalone/unlimited since startDate) and adjust `bonusWindow`/evaluation accordingly. Not attempted in this change.
+1 -1
View File
@@ -1,4 +1,4 @@
# FamChore v2 — Rules # FamDone v2 — Rules
## Auth ## Auth
- Every collection query includes `famId = @request.auth.famId` filter - Every collection query includes `famId = @request.auth.famId` filter
+1 -1
View File
@@ -1,4 +1,4 @@
# FamChore — TODO # FamDone — TODO
(Completed work is tracked in MEMORY.md / git history.) (Completed work is tracked in MEMORY.md / git history.)
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>

After

Width:  |  Height:  |  Size: 328 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 328 B

+23 -4
View File
@@ -1,13 +1,21 @@
<script lang="ts"> <script lang="ts">
let { title, subtitle, children }: { import FamDone from './FamDone.svelte';
let {
title,
subtitle,
children,
secondary
}: {
title: string; title: string;
subtitle?: string; subtitle?: string;
children?: any; children?: any;
secondary?: any;
} = $props(); } = $props();
</script> </script>
<main class="auth"> <main class="auth">
<a class="brand" href="/">FamChore</a> <a class="brand" href="/"><FamDone /></a>
<div class="card"> <div class="card">
<h1>{title}</h1> <h1>{title}</h1>
{#if subtitle} {#if subtitle}
@@ -15,6 +23,9 @@
{/if} {/if}
{@render children?.()} {@render children?.()}
</div> </div>
{#if secondary}
<div class="card secondary">{@render secondary?.()}</div>
{/if}
</main> </main>
<style> <style>
@@ -26,7 +37,10 @@
justify-content: center; justify-content: center;
padding: 2rem 1rem; padding: 2rem 1rem;
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%); background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
color: #1f2937; color: #1f2937;
} }
.brand { .brand {
@@ -55,7 +69,12 @@
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
} }
.card.secondary {
margin-top: 1rem;
}
@media (max-width: 480px) { @media (max-width: 480px) {
.card { padding: 1.5rem; } .card {
padding: 1.5rem;
}
} }
</style> </style>
@@ -0,0 +1,32 @@
<script lang="ts">
let { tag = 'span' }: { tag?: 'span' | 'h1' | 'h2' | 'h3' | 'a' | 'strong' | 'p' } = $props();
</script>
<svelte:element this={tag} class="fam-done">
Fam<span class="text-green-500">Done</span><svg
class="fam-done-check"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="#10b981"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg
></svelte:element
>
<style>
.fam-done {
font-weight: inherit;
color: inherit;
}
.fam-done-check {
font-size: 0.6em;
vertical-align: super;
position: relative;
display: inline-block;
}
</style>
+69 -7
View File
@@ -1,13 +1,75 @@
<footer class="app-footer"> <script lang="ts">
<span class="footer-text">FamChore</span> import FamDone from './FamDone.svelte';
// Offset the footer content past the fixed sidebar on fam-scoped pages
// (220px desktop / 56px mobile). Standalone pages (landing, pricing, admin)
// have no sidebar and leave this false.
let { sidebar = false } = $props();
</script>
<footer class="bg-slate-100 text-[#4c1d95] border-t border-[#4338ca]">
<div class="footer-inner px-8 py-16" class:sidebar>
<div class="footer-row flex items-center justify-between gap-2">
<span class="footer-star">✦</span>
<span class="footer-copy"
>&copy; {new Date().getFullYear()}
<span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span
>
<aside class="footer-aside text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm">
<span>Built by humans</span>
<a href="https://threejjjs.xyz">
<h5 class="m-0 text-right font-tertiary text-xl md:text-3xl">
three<span class="opacity-55">jjj</span>s
<br />
</h5>
</a>
<span class="text-xs">Interactive Tech</span>
</aside>
</div>
</div>
</footer> </footer>
<style> <style>
.app-footer { /* Offset the footer content past the fixed sidebar so it aligns with the
padding: 1rem 1.5rem; app-main content column (220px desktop / 56px mobile). */
border-top: 1px solid #e5e7eb; .footer-inner.sidebar {
margin-left: 220px;
}
@media (max-width: 1023px) {
.footer-inner.sidebar {
margin-left: 56px;
}
}
.footer-star {
font-size: 2.5rem;
line-height: 1;
color: #4c1d95;
flex-shrink: 0;
position: absolute;
left: 2rem;
}
.footer-copy {
flex: 1;
text-align: center; text-align: center;
font-size: 0.8rem; padding: 0 1rem;
color: #9ca3af; }
.footer-row {
position: relative;
}
.footer-aside {
position: absolute;
right: 2rem;
}
@media (max-width: 639px) {
.footer-star {
font-size: 1.75rem;
left: 1rem;
}
.footer-copy {
padding: 0 0.5rem;
}
.footer-aside {
right: 1rem;
}
} }
</style> </style>
+3 -2
View File
@@ -10,6 +10,7 @@
logoutIcon, logoutIcon,
prefsIcon prefsIcon
} from './icons'; } from './icons';
import FamDone from './FamDone.svelte';
let { famName = '', session = null, isParent = false, role = 'child' } = $props(); let { famName = '', session = null, isParent = false, role = 'child' } = $props();
@@ -43,7 +44,7 @@
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
: []), : []),
{ {
href: session ? '/logout' : role === 'child' ? `/${famSlug}/join` : '/login', href: session ? '/logout' : '/login',
label: session ? 'Log out' : 'Log in', label: session ? 'Log out' : 'Log in',
icon: logoutIcon icon: logoutIcon
} }
@@ -53,7 +54,7 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<span class="app-icon">✦</span> <span class="app-icon">✦</span>
<span class="app-name">FamChore</span> <span class="app-name"><FamDone /></span>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
+1 -1
View File
@@ -39,7 +39,7 @@
.topnav { .topnav {
position: fixed; position: fixed;
top: 0; left: 220px; right: 0; top: 0; left: 220px; right: 0;
height: 48px; height: 52px;
background: #fff; background: #fff;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
display: flex; display: flex;
+1
View File
@@ -1,4 +1,5 @@
export { default as Sidebar } from './Sidebar.svelte'; export { default as Sidebar } from './Sidebar.svelte';
export { default as FamDone } from './FamDone.svelte';
export { default as TopNav } from './TopNav.svelte'; export { default as TopNav } from './TopNav.svelte';
export { default as Footer } from './Footer.svelte'; export { default as Footer } from './Footer.svelte';
export { default as ViewHeader } from './ViewHeader.svelte'; export { default as ViewHeader } from './ViewHeader.svelte';
+1 -1
View File
@@ -34,7 +34,7 @@ export async function sendParentInviteEmail(opts: {
await resend.emails.send({ await resend.emails.send({
from: 'no-reply@famchamp.ai', from: 'no-reply@famchamp.ai',
to, to,
subject: `You're invited to ${famName} on FamChore`, subject: `You're invited to ${famName} on FamDone`,
html: ` html: `
<p>You've been invited as a parent on <strong>${famName}</strong>.</p> <p>You've been invited as a parent on <strong>${famName}</strong>.</p>
<p><a href="${inviteLink}">Click here to join</a> and set up your password.</p> <p><a href="${inviteLink}">Click here to join</a> and set up your password.</p>
+12
View File
@@ -442,6 +442,8 @@ async function ensureAssignedChoreFields(): Promise<void> {
if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false }); if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false });
if (!has('color')) needed.push({ name: 'color', type: 'text', required: false }); if (!has('color')) needed.push({ name: 'color', type: 'text', required: false });
if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false }); if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false });
// Shared chores: memberId becomes optional, shared boolean tracks original mode
if (!has('shared')) needed.push({ name: 'shared', type: 'bool', required: false });
// Todos can be celebration-only (emoji) as well as points/money. // Todos can be celebration-only (emoji) as well as points/money.
let changed = !!needed.length; let changed = !!needed.length;
const typeField = col.fields.find((f: any) => f.name === 'type'); const typeField = col.fields.find((f: any) => f.name === 'type');
@@ -455,6 +457,12 @@ async function ensureAssignedChoreFields(): Promise<void> {
tplField.required = false; tplField.required = false;
changed = true; changed = true;
} }
// memberId must be optional for shared chores (null = shared with all members)
const memberField = col.fields.find((f: any) => f.name === 'memberId');
if (memberField && memberField.required) {
memberField.required = false;
changed = true;
}
if (changed) { if (changed) {
await updateCollection(col.id, { ...col, fields: [...col.fields] }); await updateCollection(col.id, { ...col, fields: [...col.fields] });
} }
@@ -513,6 +521,10 @@ async function ensureBonusFields(): Promise<void> {
changed = true; changed = true;
} }
} }
if (name === 'bonus_configs' && !has('targetChoreId')) {
fields.push({ name: 'targetChoreId', type: 'text', required: false });
changed = true;
}
// rewardValue must be optional so an unset pocket-money droplet can exist. // rewardValue must be optional so an unset pocket-money droplet can exist.
const rv = fields.find((f: any) => f.name === 'rewardValue'); const rv = fields.find((f: any) => f.name === 'rewardValue');
if (rv && rv.required) { if (rv && rv.required) {
+18 -2
View File
@@ -58,7 +58,15 @@ export async function evaluateFam(pb: any, famId: string) {
// Pocket money pauses until the parent sets an amount. // Pocket money pauses until the parent sets an amount.
if (cfg.isPocketMoney && !cfg.rewardValue) continue; if (cfg.isPocketMoney && !cfg.rewardValue) continue;
const win = bonusWindow(cfg, paydayEval, tzEval); const win = bonusWindow(cfg, paydayEval, tzEval);
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); let periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
// Count-type rewards can pin against a single assigned chore (targetChoreId).
// When set, only completions of that specific chore count toward the target.
if (cfg.type === 'count' && cfg.targetChoreId) {
periodCompletions = periodCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
}
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
let createdReward = false; let createdReward = false;
@@ -241,7 +249,15 @@ export async function progress(pb: any, famId: string) {
const win = bonusWindow(cfg, payday, tz); const win = bonusWindow(cfg, payday, tz);
const pStart = win.from; const pStart = win.from;
const pEnd = win.to; const pEnd = win.to;
const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win)); let periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
// Count-type rewards pinned to a specific chore only count that chore's
// completions toward the target.
if (cfg.type === 'count' && cfg.targetChoreId) {
periodCompletions = periodCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
}
const progressRows: any[] = []; const progressRows: any[] = [];
+35 -1
View File
@@ -1,10 +1,13 @@
import { famMeta } from './fam'; import { famMeta } from './fam';
import { weekStart } from '@shared/timezone';
// Aggregated kanban payload for a single member (child session). // Aggregated kanban payload for a single member (child session).
export async function myChores(pb: any, famId: string, memberId: string) { export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
}),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
@@ -20,4 +23,35 @@ export async function myChores(pb: any, famId: string, memberId: string) {
paydayTime, paydayTime,
timezone: tz timezone: tz
}; };
}
// Purge stale todos. A todo is a one-off item with a completeBy deadline; if it
// is never completed it lingers in `assigned_chores` forever (shown as EXPIRED).
// Once a new week has started past the deadline's week we delete it so it
// doesn't accumulate. Must be called with a client that can delete (parent /
// superuser) — child clients can't delete assigned_chores.
export async function cleanupExpiredTodos(pb: any, famId: string): Promise<number> {
const { payday, tz } = await famMeta(pb, famId);
const ws = weekStart(payday, tz);
const todos = await pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && isTodo = true && completeBy != ''`
});
const completions = await pb
.collection('completions')
.getFullList({ filter: `famId = '${famId}'` })
.catch(() => []);
const doneIds = new Set(completions.map((c: any) => c.assignedChoreId));
let removed = 0;
for (const todo of todos) {
// Only consider uncompleted todos whose deadline fell before this week.
if (doneIds.has(todo.id)) continue;
if (!todo.completeBy || todo.completeBy >= ws) continue;
await pb.collection('assigned_chores').delete(todo.id).catch(() => {});
removed++;
}
if (removed) console.log(`[chores] Purged ${removed} expired todo(s) for fam ${famId}`);
return removed;
} }
@@ -5,7 +5,9 @@ import { evaluateFam } from './bonuses';
export async function myChores(pb: any, famId: string, memberId: string) { export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
}),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
@@ -32,8 +34,15 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` }); .getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` });
const chore = choreList?.[0]; const chore = choreList?.[0];
const isTodo = chore?.isTodo; const isTodo = chore?.isTodo;
const isShared = chore?.shared === true || !chore?.memberId;
let filter: string; let filter: string;
if (isTodo) { if (isShared && !isTodo) {
// Shared non-todo: any completion in the period = already claimed
const { payday, tz } = await famMeta(pb, famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`;
} else if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`; filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else { } else {
const { payday, tz } = await famMeta(pb, famId); const { payday, tz } = await famMeta(pb, famId);
@@ -49,6 +58,10 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isTodo && existing[0].rewardId) { if (isTodo && existing[0].rewardId) {
await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {}); await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {});
} }
// If this was a shared chore claimed by this member, un-share it
if (isShared && existing[0].memberId === memberId) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId: '' });
}
evaluateFam(pb, famId).catch(() => {}); evaluateFam(pb, famId).catch(() => {});
return { completed: false }; return { completed: false };
} }
@@ -74,6 +87,10 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
...(rewardId ? { rewardId } : {}) ...(rewardId ? { rewardId } : {})
}); });
// Claim the shared chore by setting memberId
if (isShared) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId });
}
evaluateFam(pb, famId).catch(() => {}); evaluateFam(pb, famId).catch(() => {});
return { completed: true, record }; return { completed: true, record };
} }
@@ -83,7 +100,15 @@ export async function revoke(pb: any, famId: string, completionId: string) {
.collection('completions') .collection('completions')
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` }); .getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
if (!completions?.length) throw new Error('Completion not found'); if (!completions?.length) throw new Error('Completion not found');
const completion = completions[0];
await pb.collection('completions').delete(completionId); await pb.collection('completions').delete(completionId);
// If this was a shared chore, clear memberId to make it available again
const choreList = await pb.collection('assigned_chores')
.getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` });
const chore = choreList?.[0];
if (chore?.shared === true) {
await pb.collection('assigned_chores').update(chore.id, { memberId: '' });
}
evaluateFam(pb, famId).catch(() => {}); evaluateFam(pb, famId).catch(() => {});
return { revoked: true }; return { revoked: true };
} }
+3
View File
@@ -8,6 +8,7 @@ import {
} from '@shared/timezone'; } from '@shared/timezone';
import { slugify } from '@shared/slugify'; import { slugify } from '@shared/slugify';
import { evaluateFam } from './bonuses'; import { evaluateFam } from './bonuses';
import { cleanupExpiredTodos } from './chores';
function resolveServerTz(tz?: string): string { function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto'); return resolveTz(tz || 'auto');
@@ -236,6 +237,8 @@ export async function completeWeek(pb: any, famId: string) {
const ws = weekStart(payday, tz); const ws = weekStart(payday, tz);
await evaluateFam(pb, famId); await evaluateFam(pb, famId);
// Start of a new week → purge todos whose deadline was in a prior week.
await cleanupExpiredTodos(pb, famId).catch(() => {});
const [members, assigned, completions] = await Promise.all([ const [members, assigned, completions] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
+2 -1
View File
@@ -51,7 +51,8 @@ export function createServices(
remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id) remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id)
}, },
chores: { chores: {
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid) myChores: (famId: string) => choresSvc.myChores(pb, famId, uid),
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
}, },
completions: { completions: {
toggle: (famId: string, body: { assignedChoreId: string; date: string }) => toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Cookies } from '@sveltejs/kit';
// the token server-side via the layout load (pbToken) and seeds pb.authStore. // the token server-side via the layout load (pbToken) and seeds pb.authStore.
// Secure flag is set in prod so it's only sent over HTTPS. // Secure flag is set in prod so it's only sent over HTTPS.
export const SESSION_COOKIE = 'pb_token'; export const SESSION_COOKIE = 'pb_token';
const MAX_AGE = 60 * 60 * 24; // 24h, PB token exp is the real ceiling const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration
export function setSessionCookie(cookies: Cookies, token: string) { export function setSessionCookie(cookies: Cookies, token: string) {
cookies.set(SESSION_COOKIE, token, { cookies.set(SESSION_COOKIE, token, {
+3 -1
View File
@@ -84,7 +84,8 @@ export interface ChoreTemplate {
export interface AssignedChore { export interface AssignedChore {
id: string; id: string;
famId: string; famId: string;
memberId: string; memberId: string | null; // null = shared with all members
shared?: boolean; // true = initially created as shared chore
templateId?: string; templateId?: string;
frequency: Frequency; frequency: Frequency;
type: RewardType; type: RewardType;
@@ -137,6 +138,7 @@ export interface BonusConfig {
criteriaValue?: number; criteriaValue?: number;
period?: BonusPeriod; period?: BonusPeriod;
status: BonusStatus; status: BonusStatus;
targetChoreId?: string;
isPocketMoney?: boolean; isPocketMoney?: boolean;
completeBy?: 'unlimited' | 'week' | 'custom'; completeBy?: 'unlimited' | 'week' | 'custom';
startDate?: string; startDate?: string;
+223 -57
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import FamDone from '$lib/components/FamDone.svelte';
import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons'; import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons';
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut'; import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
@@ -13,7 +14,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>FamChore — Chores done. Allowance earned.</title> <title>FamDone — Chores done. Allowance earned.</title>
<meta <meta
name="description" name="description"
content="Turn family chores into points and pocket money. Assign chores, kids complete them, allowances are calculated automatically." content="Turn family chores into points and pocket money. Assign chores, kids complete them, allowances are calculated automatically."
@@ -35,13 +36,24 @@
{/if} {/if}
<!-- HERO --> <!-- HERO -->
<section class="hero"> <section class="hero">
<div class="hero-inner"> <div class="hero-content">
<div class="hero-copy"> <span class="hero-kicker">The family chore app</span>
<h1 class="hero-title"><FamDone /></h1>
<p class="hero-sub">Every chore checked. Every allowance earned — on autopilot.</p>
<div class="hero-actions">
<a class="hero-cta" href="/signup">Create my family</a>
<a class="hero-cta-ghost" href="/pricing">See pricing</a>
</div>
</div>
</section>
<section class="intro">
<div class="intro-inner">
<div class="intro-copy">
<span class="pill">For busy families</span> <span class="pill">For busy families</span>
<h1>Chores done. <span class="accent">Allowance earned.</span></h1> <h1>Chores done. <span class="accent">Allowance earned.</span></h1>
<p class="lede"> <p class="lede">
FamChore turns everyday household chores into points and pocket money. Assign the chores, <FamDone /> turns everyday household chores into points and pocket money. Assign the chores,
let your kids see their progress live, and FamChore calculates the allowance automatically let your kids see their progress live, and <FamDone /> calculates the allowance automatically
— no spreadsheets, no nagging. — no spreadsheets, no nagging.
</p> </p>
<ul class="hero-ticks"> <ul class="hero-ticks">
@@ -70,11 +82,38 @@
</p> </p>
</div> </div>
</div> </div>
<div class="explainer">
<div class="steps">
<article class="step">
<span class="step-num">1</span>
<h3>Create your family</h3>
<p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite
code.
</p>
</article>
<article class="step">
<span class="step-num">2</span>
<h3>Kids do the work</h3>
<p>
They see today's chores as a simple card board and tick them off as they go — points are
added instantly.
</p>
</article>
<article class="step">
<span class="step-num">3</span>
<h3>Allowance pays out</h3>
<p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep
it fun.
</p>
</article>
</div>
</div>
</section> </section>
<!-- EXPLAINER --> <!-- EXPLAINER -->
<section class="explainer"> <!-- <section class="explainer">
<h2>How it works</h2>
<div class="steps"> <div class="steps">
<article class="step"> <article class="step">
<span class="step-num">1</span> <span class="step-num">1</span>
@@ -101,54 +140,62 @@
</p> </p>
</article> </article>
</div> </div>
</section> </section> -->
<!-- FEATURES --> <!-- FEATURES -->
<section class="features"> <section class="features">
<h2>Everything a family needs</h2> <div class="features-inner">
<div class="feature-grid"> <h2>Everything a family needs</h2>
<article class="feature"> <div class="feature-grid">
<h3>Chore board</h3> <article class="feature">
<p>Reusable chore templates and a simple assignment grid. Build once, reuse every week.</p> <h3>Chore board</h3>
</article> <p>
<article class="feature"> Reusable chore templates and a simple assignment grid. Build once, reuse every week.
<h3>Live progress</h3> </p>
<p> </article>
Realtime sync across the whole family — see points and progress update the moment a chore <article class="feature">
is done. <h3>Live progress</h3>
<p>
Realtime sync across the whole family — see points and progress update the moment a
chore is done.
</p>
</article>
<article class="feature">
<h3>Automatic allowance</h3>
<p>
Points convert to money using your family's own rules. No mental maths, no arguments.
</p>
</article>
<article class="feature">
<h3>Rewards &amp; bonuses</h3>
<p>
Kids can spend points on rewards, and you can run a monthly bonus for the top earner.
</p>
</article>
<article class="feature">
<h3>Kid-friendly</h3>
<p>
A simple, colourful interface kids love — big buttons, clear feedback, their own space.
</p>
</article>
<article class="feature">
<h3>Private by design</h3>
<p>
Everything is scoped to your family. Kids join with an invite code and stay in your
family.
</p>
</article>
</div>
<div class="cta">
<p>Ready to make chores painless?</p>
<a class="cta-btn" href="/signup">Create your family</a>
<p class="card-alt">
<a
class="hover:underline self-center items-center justify-center flex gap-2"
href="/pricing">or see pricing {@html chevronRightIcon}</a
>
</p> </p>
</article> </div>
<article class="feature">
<h3>Automatic allowance</h3>
<p>Points convert to money using your family's own rules. No mental maths, no arguments.</p>
</article>
<article class="feature">
<h3>Rewards &amp; bonuses</h3>
<p>Kids can spend points on rewards, and you can run a monthly bonus for the top earner.</p>
</article>
<article class="feature">
<h3>Kid-friendly</h3>
<p>
A simple, colourful interface kids love — big buttons, clear feedback, their own space.
</p>
</article>
<article class="feature">
<h3>Private by design</h3>
<p>
Everything is scoped to your family. Kids join with an invite code and stay in your
family.
</p>
</article>
</div>
<div class="cta">
<p>Ready to make chores painless?</p>
<a class="cta-btn" href="/signup">Create your family</a>
<p class="card-alt">
<a
class="hover:underline self-center items-center justify-center flex gap-2"
href="/pricing">or see pricing {@html chevronRightIcon}</a
>
</p>
</div> </div>
</section> </section>
</main> </main>
@@ -220,10 +267,120 @@
/* HERO */ /* HERO */
.hero { .hero {
position: relative;
overflow: hidden;
background: #1e1b4b;
color: #eef2ff;
padding: 6rem 1.5rem;
text-align: center;
}
.hero::before,
.hero::after {
content: '';
position: absolute;
border-radius: 50%;
filter: blur(60px);
z-index: 0;
}
.hero::before {
width: 480px;
height: 480px;
top: -180px;
left: -120px;
background: rgba(67, 56, 202, 0.55);
}
.hero::after {
width: 420px;
height: 420px;
bottom: -160px;
right: -120px;
background: rgba(245, 158, 11, 0.25);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 48rem;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.hero-kicker {
display: inline-block;
background: rgba(224, 231, 255, 0.12);
border: 1px solid rgba(224, 231, 255, 0.25);
color: #c7d2fe;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.4rem 0.95rem;
border-radius: 999px;
}
.hero-title {
margin: 0;
font-size: clamp(3rem, 8vw, 5.5rem);
line-height: 1;
font-weight: 800;
letter-spacing: -0.02em;
}
.hero-sub {
margin: 0;
max-width: 34rem;
font-size: 1.2rem;
line-height: 1.6;
color: #c7d2fe;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
margin-top: 0.75rem;
}
.hero-cta {
display: inline-block;
background: #f59e0b;
color: #1e1b4b;
text-decoration: none;
font-weight: 700;
font-size: 1rem;
padding: 0.8rem 1.75rem;
border-radius: 999px;
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.35);
transition:
transform 0.15s,
box-shadow 0.15s;
}
.hero-cta:hover {
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(245, 158, 11, 0.45);
}
.hero-cta-ghost {
display: inline-block;
color: #eef2ff;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
padding: 0.8rem 1.5rem;
border-radius: 999px;
border: 1px solid rgba(224, 231, 255, 0.35);
transition:
background 0.15s,
border-color 0.15s;
}
.hero-cta-ghost:hover {
background: rgba(224, 231, 255, 0.1);
border-color: rgba(224, 231, 255, 0.6);
}
/* INTRO (was the original hero) */
.intro {
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%); background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
padding: 4.5rem 1.5rem; padding: 4.5rem 1.5rem;
} }
.hero-inner { .intro-inner {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
display: grid; display: grid;
@@ -369,11 +526,18 @@
} }
/* EXPLAINER */ /* EXPLAINER */
.explainer, .explainer {
.features {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 4rem 1.5rem; padding: 4rem 1.5rem;
padding-top: 6em;
}
.features {
padding: 4rem 1.5rem;
}
.features-inner {
max-width: 1100px;
margin: 0 auto;
} }
.explainer h2, .explainer h2,
.features h2 { .features h2 {
@@ -417,8 +581,10 @@
/* FEATURES */ /* FEATURES */
.features { .features {
background: #f5f6fa; background: linear-gradient(170deg, #8fb6ee 0%, #dfe9fb 45%, #f5f6fa 100%);
border-radius: 0; }
.features h2 {
color: #4338ca;
} }
.feature-grid { .feature-grid {
display: grid; display: grid;
@@ -466,7 +632,7 @@
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 860px) { @media (max-width: 860px) {
.hero-inner { .intro-inner {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.steps, .steps,
+27 -17
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { onDestroy, onMount } from 'svelte'; import { onDestroy } from 'svelte';
import { initRealtimePb, pb } from '$lib/pocketbase'; import { initRealtimePb, pb } from '$lib/pocketbase';
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
@@ -39,7 +39,7 @@
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return; if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
returnHandled = true; returnHandled = true;
if (!disabled) { if (!disabled) {
notices.success('Welcome to FamChore!', 'Your subscription is active.'); notices.success('Welcome to FamDone!', 'Your subscription is active.');
return; return;
} }
startActivating(); startActivating();
@@ -146,20 +146,30 @@
); );
}); });
onMount(() => { // Client-only realtime wiring. Must be reactive (not onMount): after a
// member redeems their OTP on /{fam}/join/{username}, the 303 redirect is a
// client-side navigation that REUSES this layout instance, so onMount would
// never re-fire. Keying on data.famId ensures the PB client is re-seeded with
// the fresh token and famStore inits + subscribes the moment a session lands.
$effect(() => {
const famId = page.data.famId;
if (!famId) return;
initRealtimePb(page.data.pbToken || ''); initRealtimePb(page.data.pbToken || '');
if (page.data.famId) famStore.init(page.data.famId, page.data.fam); famStore.init(famId, page.data.fam as any);
});
$effect(() => {
const chat = page.data.chat; const chat = page.data.chat;
if (chat?.famId && chat?.actor) { if (!chat?.famId || !chat?.actor) return;
chatStore.init({ initRealtimePb(page.data.pbToken || '');
famId: chat.famId, chatStore.init({
actorId: chat.actor.id, famId: chat.famId,
actorType: chat.actor.type, actorId: chat.actor.id,
actorName: chat.actor.name, actorType: chat.actor.type,
actorColor: chat.actor.color || '#6366f1', actorName: chat.actor.name,
pbToken: page.data.pbToken || '' actorColor: chat.actor.color || '#6366f1',
}); pbToken: page.data.pbToken || ''
} });
}); });
</script> </script>
@@ -195,7 +205,7 @@
<strong>Access paused</strong> <strong>Access paused</strong>
<span> <span>
{isParent {isParent
? 'Add an access code or resume your subscription to keep using FamChore.' ? 'Add an access code or resume your subscription to keep using FamDone.'
: 'Your family access is paused.'} : 'Your family access is paused.'}
</span> </span>
{/if} {/if}
@@ -207,7 +217,7 @@
{#if claimToast} {#if claimToast}
<div class="claim-toast">{claimToast}</div> <div class="claim-toast">{claimToast}</div>
{/if} {/if}
<Footer /> <Footer sidebar />
</div> </div>
{#if chatStore.open} {#if chatStore.open}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat" <button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
@@ -260,7 +270,7 @@
} }
.app-main { .app-main {
margin-left: 220px; margin-left: 220px;
margin-top: 48px; margin-top: 52px;
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
flex: 1; flex: 1;
transition: margin-left 0.2s; transition: margin-left 0.2s;
@@ -179,9 +179,11 @@
: (data.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])
); );
let assigned = $derived( let assigned = $derived(
famStore.initialized (
? (famStore.assigned as AssignedChore[]) famStore.initialized
: (data.assigned as AssignedChore[]) ? (famStore.assigned as AssignedChore[])
: (data.assigned as AssignedChore[])
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
); );
let completions = $derived( let completions = $derived(
famStore.initialized famStore.initialized
@@ -264,7 +266,7 @@
if (role === 'parent' || !memberId) return []; if (role === 'parent' || !memberId) return [];
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id)); const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
return assigned.filter((a) => { return assigned.filter((a) => {
if (a.memberId !== memberId) return false; if (a.memberId !== null && a.memberId !== memberId) return false;
if (a.isTodo) return false; if (a.isTodo) return false;
if (!a.seasonIds || a.seasonIds.length === 0) return true; if (!a.seasonIds || a.seasonIds.length === 0) return true;
if (activeSeasonIds.size === 0) return true; if (activeSeasonIds.size === 0) return true;
@@ -275,7 +277,7 @@
// Todos — one-off items with deadlines (sorted: soonest first) // Todos — one-off items with deadlines (sorted: soonest first)
let memberTodos = $derived.by(() => let memberTodos = $derived.by(() =>
assigned assigned
.filter((a) => a.memberId === memberId && a.isTodo) .filter((a) => (a.memberId === null || a.memberId === memberId) && a.isTodo)
.sort((a, b) => { .sort((a, b) => {
const da = a.completeBy || '9999-99-99'; const da = a.completeBy || '9999-99-99';
const db = b.completeBy || '9999-99-99'; const db = b.completeBy || '9999-99-99';
@@ -347,7 +349,9 @@
// non-todo chores for the member (no season filter), weighted by the // non-todo chores for the member (no season filter), weighted by the
// number of days in the displayed period — so the hero total agrees // number of days in the displayed period — so the hero total agrees
// with the Goals progress instead of showing a different denominator. // with the Goals progress instead of showing a different denominator.
const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo); const chores = assigned.filter(
(a) => (a.memberId === null || a.memberId === memberId) && !a.isTodo
);
const periodDays = const periodDays =
Math.round( Math.round(
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) / (new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) /
@@ -491,7 +495,9 @@
// ─── Threshold Goals (bonus configs the member is chasing) ─── // ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => { let thresholdGoals = $derived.by(() => {
// Member's assigned chores (non-todo) — used to compute period potential. // Member's assigned chores (non-todo) — used to compute period potential.
const myChores = assigned.filter((a) => a.memberId === memberId && !a.isTodo); const myChores = assigned.filter(
(a) => (a.memberId === null || a.memberId === memberId) && !a.isTodo
);
return bonusConfigs return bonusConfigs
.filter((cfg) => { .filter((cfg) => {
if (cfg.status !== 'active') return false; if (cfg.status !== 'active') return false;
@@ -522,9 +528,15 @@
const isPercent = cfg.thresholdType === 'percent'; const isPercent = cfg.thresholdType === 'percent';
const isCount = cfg.type === 'count'; const isCount = cfg.type === 'count';
// Count-type rewards pinned to a single chore scoped their count
// (and the period's reclaimable potential) to that chore only.
const countChores = isCount && cfg.targetChoreId
? myChores.filter((a) => a.id === cfg.targetChoreId)
: myChores;
// Frequency-aware total potential (a daily chore = `periodDays` // Frequency-aware total potential (a daily chore = `periodDays`
// instances, a weekly chore = 1). // instances, a weekly chore = 1).
const totalPotential = myChores.reduce( const totalPotential = countChores.reduce(
(sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1), (sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1),
0 0
); );
@@ -533,7 +545,9 @@
if (isPercent) { if (isPercent) {
current = periodCompletions.length; current = periodCompletions.length;
} else if (isCount) { } else if (isCount) {
current = periodCompletions.length; current = cfg.targetChoreId
? periodCompletions.filter((c) => c.assignedChoreId === cfg.targetChoreId).length
: periodCompletions.length;
} else { } else {
current = periodCompletions.reduce((sum, c) => { current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId); const chore = assigned.find((a) => a.id === c.assignedChoreId);
@@ -569,6 +583,7 @@
const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts'; const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
const targetLabel = `${configured} ${unit}`; const targetLabel = `${configured} ${unit}`;
const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100; const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
const targetChore = isCount ? countChores[0] : null;
return { return {
config: cfg, config: cfg,
current, current,
@@ -578,6 +593,11 @@
target, target,
totalPotential, totalPotential,
isPercent, isPercent,
targetChoreName: targetChore
? targetChore.customName ||
templates.find((t) => t.id === targetChore.templateId)?.name ||
''
: '',
pct, pct,
achieved, achieved,
rewardStatus: existingReward?.status || null, rewardStatus: existingReward?.status || null,
@@ -660,6 +680,14 @@
return completions.some((c) => c.assignedChoreId === todoId); return completions.some((c) => c.assignedChoreId === todoId);
} }
// A todo whose completeBy has passed without being completed is expired —
// no longer doable for the rest of the week.
function isTodoExpired(todo: AssignedChore): boolean {
if (isTodoDone(todo.id)) return false;
if (!todo.completeBy) return false;
return todo.completeBy < todayChild;
}
function findCompletion(chore: AssignedChore) { function findCompletion(chore: AssignedChore) {
const match = (c: Completion) => const match = (c: Completion) =>
chore.isTodo chore.isTodo
@@ -675,6 +703,7 @@
async function toggle(chore: AssignedChore) { async function toggle(chore: AssignedChore) {
if (accessDisabled) return; if (accessDisabled) return;
if (chore.isTodo && isTodoExpired(chore)) return;
if (togglingIds) return; if (togglingIds) return;
togglingIds = chore.id; togglingIds = chore.id;
@@ -1252,7 +1281,11 @@
<span class="goal-badge">{@html checkCircleIcon} earned</span> <span class="goal-badge">{@html checkCircleIcon} earned</span>
{:else} {:else}
<span class="goal-pct">{pct}%</span> <span class="goal-pct">{pct}%</span>
<span class="goal-target-pill">{@html targetIcon} {goal.targetLabel}</span> <span class="goal-target-pill"
>{@html targetIcon} {goal.targetLabel}{#if goal.targetChoreName}
<span class="goal-chore"> · {goal.targetChoreName}</span>
{/if}</span
>
{/if} {/if}
</div> </div>
</div> </div>
@@ -1322,6 +1355,7 @@
{/each} {/each}
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo} {#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
{@const urgency = todoUrgency(todo)} {@const urgency = todoUrgency(todo)}
{@const expired = isTodoExpired(todo)}
{#if todo.type === 'emoji'} {#if todo.type === 'emoji'}
<button <button
class="chore todo-chore" class="chore todo-chore"
@@ -1330,14 +1364,17 @@
class:todo-orange={urgency === 'orange'} class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'} class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
class:todo-expired={expired}
onclick={() => toggle(todo)} onclick={() => toggle(todo)}
disabled={!!togglingIds} disabled={!!togglingIds || expired}
> >
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="todo-main"> <span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span> <span class="chore-name">{todo.customName || 'Todo'}</span>
{#if todo.completeBy} {#if todo.completeBy}
{#if todo.completeBy === todayChild} {#if expired}
<span class="todo-due due-expired">EXPIRED</span>
{:else if todo.completeBy === todayChild}
<span class="todo-due due-today">DUE TODAY!</span> <span class="todo-due due-today">DUE TODAY!</span>
{:else} {:else}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span> <span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
@@ -1355,14 +1392,17 @@
class:todo-orange={urgency === 'orange'} class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'} class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
class:todo-expired={expired}
onclick={() => toggle(todo)} onclick={() => toggle(todo)}
disabled={!!togglingIds} disabled={!!togglingIds || expired}
> >
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="todo-main"> <span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span> <span class="chore-name">{todo.customName || 'Todo'}</span>
{#if todo.completeBy} {#if todo.completeBy}
{#if todo.completeBy === todayChild} {#if expired}
<span class="todo-due due-expired">EXPIRED</span>
{:else if todo.completeBy === todayChild}
<span class="todo-due due-today">DUE TODAY!</span> <span class="todo-due due-today">DUE TODAY!</span>
{:else} {:else}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span> <span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
@@ -2214,6 +2254,10 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.goal-chore {
font-weight: 600;
color: #6b7280;
}
.goal-achieved .goal-bar-fill { .goal-achieved .goal-bar-fill {
background: linear-gradient(90deg, #10b981, #34d399); background: linear-gradient(90deg, #10b981, #34d399);
} }
@@ -2350,6 +2394,20 @@
.chore.todo-black { .chore.todo-black {
border-left-color: #1f2937; border-left-color: #1f2937;
} }
.chore.todo-expired {
border-left-color: #9ca3af;
background: #f3f4f6;
opacity: 0.75;
}
.chore.todo-expired .chore-name {
text-decoration: line-through;
color: #6b7280;
}
.todo-due.due-expired {
color: #ef4444;
font-weight: 700;
letter-spacing: 0.03em;
}
.todo-label { .todo-label {
font-size: 0.62rem; font-size: 0.62rem;
font-weight: 700; font-weight: 700;
@@ -1,11 +1,15 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase'; import { pbUser } from '$lib/server/pocketbase';
import { cleanupExpiredTodos } from '$lib/server/services/chores';
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types'; import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
export async function load(event) { export async function load(event) {
if (!event.locals.user) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId; const famId = event.locals.user.famId;
const pb = pbUser(event); const pb = pbUser(event);
// Defensive sweep: purge todos whose deadline was in a prior week. Runs as
// the parent (delete permission), no-ops if there's nothing to remove.
await cleanupExpiredTodos(pb, famId).catch(() => {});
const [templates, members, assigned, seasons, completions] = await Promise.all([ const [templates, members, assigned, seasons, completions] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }), pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }),
pb.collection('users').getFullList({ pb.collection('users').getFullList({
@@ -28,8 +28,10 @@
// Assign-from-template modal (platform templates are copied into the family // Assign-from-template modal (platform templates are copied into the family
// as assigned chores; the fam admin amends before saving). // as assigned chores; the fam admin amends before saving).
let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null); let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null);
let assignStep = $state(1);
let assignName = $state(''); let assignName = $state('');
let assignDescription = $state(''); let assignDescription = $state('');
let assignTarget = $state<'member' | 'shared'>('member');
let assignColor = $state(''); let assignColor = $state('');
let assignIcon = $state(''); let assignIcon = $state('');
let assignFreq = $state('daily'); let assignFreq = $state('daily');
@@ -37,6 +39,9 @@
let assignValue = $state(10); let assignValue = $state(10);
let assignSeasonIds = $state<string[]>([]); let assignSeasonIds = $state<string[]>([]);
const STEP_SVG = (n: number) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><text x="12" y="16" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor" stroke="none">${n}</text></svg>`;
// Todo form state // Todo form state
let todoName = $state(''); let todoName = $state('');
let todoType = $state<'points' | 'money' | 'emoji'>('points'); let todoType = $state<'points' | 'money' | 'emoji'>('points');
@@ -123,6 +128,10 @@
return assigned.filter((a) => a.memberId === memberId && !a.isTodo); return assigned.filter((a) => a.memberId === memberId && !a.isTodo);
} }
function sharedChoresFiltered(): AssignedChore[] {
return filteredAssigned.filter((a) => !a.memberId && !a.isTodo);
}
function isGlobalChore(a: AssignedChore): boolean { function isGlobalChore(a: AssignedChore): boolean {
return !a.seasonIds || a.seasonIds.length === 0; return !a.seasonIds || a.seasonIds.length === 0;
} }
@@ -169,6 +178,8 @@
const t = templates.find((x) => x.id === tid); const t = templates.find((x) => x.id === tid);
if (!t) return; if (!t) return;
assignModal = { t, memberId }; assignModal = { t, memberId };
assignStep = 1;
assignTarget = memberId ? 'member' : 'shared';
assignName = t.name || ''; assignName = t.name || '';
assignDescription = t.description || ''; assignDescription = t.description || '';
assignColor = t.color || ''; assignColor = t.color || '';
@@ -183,8 +194,9 @@
if (!assignModal) return; if (!assignModal) return;
const s = page.data.session as any; const s = page.data.session as any;
if (!s) return; if (!s) return;
const isShared = assignTarget === 'shared';
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
memberId: assignModal.memberId, memberId: isShared ? '' : assignModal.memberId,
templateId: assignModal.t.id, templateId: assignModal.t.id,
customName: assignName, customName: assignName,
description: assignDescription, description: assignDescription,
@@ -192,7 +204,8 @@
icon: assignIcon, icon: assignIcon,
frequency: assignFreq, frequency: assignFreq,
type: assignType, type: assignType,
value: assignValue value: assignValue,
shared: isShared
}; };
if (assignSeasonIds.length) body.seasonIds = assignSeasonIds; if (assignSeasonIds.length) body.seasonIds = assignSeasonIds;
await fetch(`/api/admin/${s.famId}/assigned-chores`, { await fetch(`/api/admin/${s.famId}/assigned-chores`, {
@@ -205,6 +218,7 @@
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
assignModal = null; assignModal = null;
assignStep = 1;
} }
// Accordion state per member: which section is open // Accordion state per member: which section is open
@@ -276,6 +290,14 @@
return completions.some((c: Completion) => c.assignedChoreId === todoId); return completions.some((c: Completion) => c.assignedChoreId === todoId);
} }
// A todo whose completeBy has passed without being completed is expired —
// no longer doable for the rest of the week.
function isTodoExpired(a: AssignedChore): boolean {
if (isTodoCompleted(a.id)) return false;
if (!a.completeBy) return false;
return a.completeBy < todayInTz(famTz);
}
// Traffic light for admin todo cards (same logic as child page) // Traffic light for admin todo cards (same logic as child page)
function todoUrgency(a: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' { function todoUrgency(a: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
if (!a.completeBy) return 'blue'; if (!a.completeBy) return 'blue';
@@ -369,10 +391,14 @@
style="border-left-color:{outlineColor(t.color)}; background:{accentBg(t.color)}" style="border-left-color:{outlineColor(t.color)}; background:{accentBg(t.color)}"
> >
<div class="card-body"> <div class="card-body">
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} /> <span class="card-title-line">
<strong>{t.name}</strong> <TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
<span class="badge">{t.defaultFrequency}</span> <strong>{t.name}</strong>
<span class="badge type">{t.defaultType}</span> </span>
<span class="card-badge-line">
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
</span>
</div> </div>
<div class="card-value"> <div class="card-value">
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue} {t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
@@ -433,18 +459,20 @@
<div class="accordion-body"> <div class="accordion-body">
{#each visibleTodosForMember(m.id) as a} {#each visibleTodosForMember(m.id) as a}
{@const completed = isTodoCompleted(a.id)} {@const completed = isTodoCompleted(a.id)}
{@const expired = isTodoExpired(a)}
{@const urgency = todoUrgency(a)} {@const urgency = todoUrgency(a)}
<div <div
class="todo-admin-card" class="todo-admin-card"
class:todo-completed={completed} class:todo-completed={completed}
class:todo-blue-bg={!completed && urgency === 'blue'} class:todo-expired={expired && !completed}
class:todo-green-bg={!completed && urgency === 'green'} class:todo-blue-bg={!completed && !expired && urgency === 'blue'}
class:todo-orange-bg={!completed && urgency === 'orange'} class:todo-green-bg={!completed && !expired && urgency === 'green'}
class:todo-red-bg={!completed && urgency === 'red'} class:todo-orange-bg={!completed && !expired && urgency === 'orange'}
class:todo-black-bg={!completed && urgency === 'black'} class:todo-red-bg={!completed && !expired && urgency === 'red'}
onclick={() => !completed && openEdit(a)} class:todo-black-bg={!completed && !expired && urgency === 'black'}
onclick={() => !completed && !expired && openEdit(a)}
role="button" role="button"
tabindex={completed ? -1 : 0} tabindex={completed || expired ? -1 : 0}
> >
<div class="todo-admin-body"> <div class="todo-admin-body">
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong> <strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
@@ -468,6 +496,8 @@
{#if completed} {#if completed}
<span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span <span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span
> >
{:else if expired}
<span class="todo-expired-badge">EXPIRED</span>
{:else} {:else}
<button <button
class="del-btn" class="del-btn"
@@ -497,11 +527,11 @@
class="accordion-wrapper accordion-chores" class="accordion-wrapper accordion-chores"
class:open={accordionState[m.id]?.chores ?? true} class:open={accordionState[m.id]?.chores ?? true}
> >
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}> <button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-title"> <span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span> <span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Chores</span> <span class="accordion-label">Chores</span>
</span> </span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span >{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
@@ -523,19 +553,23 @@
)}" )}"
> >
<div class="card-body"> <div class="card-body">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} /> <span class="card-title-line">
<strong>{a.customName || tName}</strong> <TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<span class="badge">{a.frequency}</span> <strong>{a.customName || tName}</strong>
{#each a.seasonIds || [] as sid} </span>
<span <span class="card-badge-line">
class="season-dot" <span class="badge">{a.frequency}</span>
title={seasonName(sid)} {#each a.seasonIds || [] as sid}
style="background:{seasonColor(sid)}" <span
></span> class="season-dot"
{/each} title={seasonName(sid)}
{#if (a.seasonIds || []).length === 0} style="background:{seasonColor(sid)}"
<span class="badge season muted">All year</span> ></span>
{/if} {/each}
{#if (a.seasonIds || []).length === 0}
<span class="badge season muted">All year</span>
{/if}
</span>
</div> </div>
<div class="card-value"> <div class="card-value">
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`} {a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
@@ -563,6 +597,89 @@
</div> </div>
</div> </div>
{/each} {/each}
<!-- Shared column -->
<div
class="column member-col shared-col"
style="border-top-color: #6366f1; --mc: #6366f1"
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, '')}
>
<h3>
<span class="dot" style="background:#6366f1"></span>
Everyone
</h3>
<!-- CHORES accordion wrapper -->
<div
class="accordion-wrapper accordion-chores"
class:open={accordionState.shared?.chores ?? true}
>
<button class="accordion-head" onclick={() => toggleAccordion('shared', 'chores')}>
<span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Competitive Chores</span>
</span>
<span class="accordion-chevron"
>{@html
(accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
<span class="accordion-count" style="background:#6366f1"
>{sharedChoresFiltered().length}</span
>
{#if accordionState.shared?.chores ?? true}
<div class="accordion-body">
{#each sharedChoresFiltered() as a}
{@const tName = templateName(a.templateId)}
<div
class="card assigned"
onclick={() => openEdit(a)}
role="button"
tabindex="0"
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(a.color)}"
>
<div class="card-body">
<span class="card-title-line">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong>
</span>
<span class="card-badge-line">
<span class="badge">{a.frequency}</span>
{#each a.seasonIds || [] as sid}
<span
class="season-dot"
title={seasonName(sid)}
style="background:{seasonColor(sid)}"
></span>
{/each}
{#if (a.seasonIds || []).length === 0}
<span class="badge season muted">All year</span>
{/if}
</span>
</div>
<div class="card-value">
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
</div>
<button
class="del-btn"
title="Remove assignment"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
});
}}>×</button
>
</div>
{:else}
<p class="empty">Drop a chore here</p>
{/each}
</div>
{/if}
</div>
</div>
{#if members.length < 3} {#if members.length < 3}
{#each Array(3 - members.length) as _} {#each Array(3 - members.length) as _}
<div class="column column-placeholder"></div> <div class="column column-placeholder"></div>
@@ -598,80 +715,122 @@
</div> </div>
{/snippet} {/snippet}
<!-- Assign from Template Modal --> <!-- Assign from Template Modal (Who / How / What wizard) -->
{#if assignModal} {#if assignModal}
<div <div
class="overlay" class="overlay"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) assignModal = null; if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; }
}} }}
role="presentation" role="presentation"
> >
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Assign: {assignModal.t.name}</h3> <h3>Assign: {assignModal.t.name}</h3>
<p class="hint">
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId) <!-- Step tabs -->
?.name}'s chores. Amend, then Assign. <div class="step-tabs" role="tablist">
</p> <button class="step-tab" class:active={assignStep === 1} onclick={() => (assignStep = 1)}>
<label>Name <input bind:value={assignName} placeholder="Task name" /></label> {@html STEP_SVG(1)}<span>Who</span>
<label </button>
>Description <input <button class="step-tab" class:active={assignStep === 2} onclick={() => (assignStep = 2)}>
bind:value={assignDescription} {@html STEP_SVG(2)}<span>How</span>
placeholder="Optional description" </button>
/></label <button class="step-tab" class:active={assignStep === 3} onclick={() => (assignStep = 3)}>
> {@html STEP_SVG(3)}<span>What</span>
<div class="color-field"> </button>
<span class="field-label">Colour</span> </div>
<div class="color-swatches">
{#each TEMPLATE_COLORS as c} {#if assignStep === 1}
<button <!-- Step 1: Who -->
type="button" <label>Name <input bind:value={assignName} placeholder="Task name" /></label>
class="swatch" <label>Description <input bind:value={assignDescription} placeholder="Optional description" /></label>
class:selected={assignColor === c.value} <label>
style="background:{c.value || 'transparent'}; border-color:{c.chromatic === Assign to
'transparent' <select bind:value={assignTarget} onchange={() => {
? '#e5e7eb' if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
: c.value}" assignModal = { ...assignModal!, memberId: members[0].id };
title={c.label} }
onclick={() => (assignColor = c.value)} }}>
></button> <option value="member">A specific member</option>
{/each} <option value="shared">Shared (all members)</option>
</select>
</label>
{#if assignTarget === 'member'}
<label>
Member
<select bind:value={assignModal.memberId}>
{#each members as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
{:else if assignStep === 2}
<!-- Step 2: How -->
<label>
Frequency
<select bind:value={assignFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select bind:value={assignType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Value
<input type="number" step="any" bind:value={assignValue} />
</label>
<div class="season-field">
<span class="field-label">Seasons</span>
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
</div> </div>
</div> {:else}
<label class="icon-row" <!-- Step 3: What -->
><span class="icon-preview" <div class="color-field">
><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span <span class="field-label">Colour</span>
> <div class="color-swatches">
<select bind:value={assignIcon}> {#each TEMPLATE_COLORS as c}
<option value="">None</option> <button
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each} type="button"
</select> class="swatch"
</label> class:selected={assignColor === c.value}
<label> style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
Frequency 'transparent'
<select bind:value={assignFreq}> ? '#e5e7eb'
<option value="daily">Daily</option> : c.value}"
<option value="weekly">Weekly</option> title={c.label}
</select> onclick={() => (assignColor = c.value)}
</label> ></button>
<label> {/each}
Type </div>
<select bind:value={assignType}> </div>
<option value="points">Points</option> <label class="icon-row"
<option value="money">Money</option> ><span class="icon-preview"
</select> ><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span
</label> >
<label> <select bind:value={assignIcon}>
Value <option value="">None</option>
<input type="number" step="any" bind:value={assignValue} /> {#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
</label> </select>
<div class="season-field"> </label>
<span class="field-label">Seasons</span> {/if}
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" onclick={() => (assignModal = null)}>Cancel</button> {#if assignStep > 1}
<button type="button" onclick={assignChore}>Assign</button> <button type="button" onclick={() => assignStep--}>Back</button>
{:else}
<button type="button" onclick={() => { assignModal = null; assignStep = 1; }}>Cancel</button>
{/if}
{#if assignStep < 3}
<button type="button" class="primary" onclick={() => assignStep++}>Next</button>
{:else}
<button type="button" class="primary" onclick={assignChore}>Assign</button>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -992,9 +1151,9 @@
} }
.template-list .card.template .card-body { .template-list .card.template .card-body {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
align-items: center; align-items: flex-start;
gap: 0.4rem; gap: 0.15rem;
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
.template-list { .template-list {
@@ -1007,12 +1166,14 @@
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */ /* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban { .kanban {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem; gap: 0.75rem;
min-width: 450px; overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
} }
.column { .column {
flex: 0 0 450px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1; border-top: 4px solid #6366f1;
border-radius: 12px; border-radius: 12px;
@@ -1085,7 +1246,7 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
position: relative; position: relative;
transition: transition:
@@ -1100,9 +1261,21 @@
.card-body { .card-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.card-title-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
min-width: 0;
}
.card-badge-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.15rem;
} }
.card-body strong { .card-body strong {
font-size: 0.85rem; font-size: 0.85rem;
@@ -1129,6 +1302,13 @@
background: #fce7f3; background: #fce7f3;
color: #be185d; color: #be185d;
} }
.shared-badge {
background: #ede9fe;
color: #5b21b6;
}
.shared-col {
flex: 0 0 450px;
}
.card-actions { .card-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1374,6 +1554,38 @@
background: #dc2626; background: #dc2626;
color: white; color: white;
} }
.modal-actions button.primary {
background: #6366f1;
color: white;
}
.step-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.step-tab {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: #9ca3af;
transition: all 0.15s;
}
.step-tab.active {
color: #6366f1;
background: #eef2ff;
font-weight: 600;
}
.step-tab:hover:not(.active) {
background: #f3f4f6;
}
/* ── Accordion wrappers ── */ /* ── Accordion wrappers ── */
.accordion-wrapper { .accordion-wrapper {
@@ -1587,6 +1799,32 @@
height: 0.95em; height: 0.95em;
} }
/* ── Expired todo ── */
.todo-admin-card.todo-expired {
opacity: 0.6;
cursor: default;
background: #f3f4f6;
border-left-color: #9ca3af;
}
.todo-admin-card.todo-expired:hover {
transform: none;
box-shadow: none;
}
.todo-admin-card.todo-expired .todo-admin-name {
text-decoration: line-through;
color: #6b7280;
}
.todo-expired-badge {
font-size: 0.65rem;
font-weight: 700;
color: #dc2626;
background: #fee2e2;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
white-space: nowrap;
}
/* ── Points badge ── */ /* ── Points badge ── */
.badge-pts { .badge-pts {
background: #e0e7ff; background: #e0e7ff;
@@ -115,6 +115,8 @@ export const actions = {
const period = fd.get('period'); const period = fd.get('period');
if (period !== null) data.period = period; if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = ''; if (data.occurrence === 'once') data.period = '';
const targetChoreId = fd.get('targetChoreId');
if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -159,6 +161,8 @@ export const actions = {
const period = fd.get('period'); const period = fd.get('period');
if (period !== null) data.period = period; if (period !== null) data.period = period;
if (occurrence === 'once') data.period = ''; if (occurrence === 'once') data.period = '';
const targetChoreId = fd.get('targetChoreId');
if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -212,6 +216,7 @@ export const actions = {
memberId: fd.get('memberId') || '', memberId: fd.get('memberId') || '',
status, status,
isPocketMoney: !!fd.get('isPocketMoney'), isPocketMoney: !!fd.get('isPocketMoney'),
targetChoreId: fd.get('targetChoreId') || '',
completeBy: fd.get('completeBy') || 'unlimited', completeBy: fd.get('completeBy') || 'unlimited',
startDate: fd.get('startDate') || '', startDate: fd.get('startDate') || '',
completeByDate: fd.get('completeByDate') || '' completeByDate: fd.get('completeByDate') || ''
@@ -89,7 +89,8 @@
startMode: 'today' as string, startMode: 'today' as string,
completeBy: 'unlimited' as string, completeBy: 'unlimited' as string,
startDate: '' as string, startDate: '' as string,
completeByDate: '' as string completeByDate: '' as string,
targetChoreId: '' as string
}); });
let configs = $derived( let configs = $derived(
@@ -116,6 +117,14 @@
// Standalone (no period) rewards can scope progress by completeBy + dates. // Standalone (no period) rewards can scope progress by completeBy + dates.
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period); let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
// Target Chore options: per the selected member, their assigned (non-todo)
// chores. When no member is selected (All), list every family chore.
let targetChoreOptions = $derived(
famStore.assigned.filter(
(a) => (editVals.memberId ? a.memberId === editVals.memberId : true) && !a.isTodo
)
);
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global));
let allBonusConfigs = $derived( let allBonusConfigs = $derived(
configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[]
@@ -194,7 +203,8 @@
startMode: 'today', startMode: 'today',
completeBy: 'unlimited', completeBy: 'unlimited',
startDate: todayIso(), startDate: todayIso(),
completeByDate: '' completeByDate: '',
targetChoreId: ''
}; };
step = 1; step = 1;
showEditModal = true; showEditModal = true;
@@ -246,7 +256,8 @@
startMode: cfg.status === 'disabled' ? 'disabled' : 'today', startMode: cfg.status === 'disabled' ? 'disabled' : 'today',
completeBy: cfg.completeBy || 'unlimited', completeBy: cfg.completeBy || 'unlimited',
startDate: cfg.startDate || todayIso(), startDate: cfg.startDate || todayIso(),
completeByDate: cfg.completeByDate || '' completeByDate: cfg.completeByDate || '',
targetChoreId: cfg.targetChoreId || ''
}; };
step = 1; step = 1;
showEditModal = true; showEditModal = true;
@@ -372,6 +383,11 @@
<div class="bar-fill" style="width:{pct}%" class:met={achieved}></div> <div class="bar-fill" style="width:{pct}%" class:met={achieved}></div>
</div> </div>
{/if} {/if}
{#if cfg.type === 'count'}
<span class="count-progress">
{Math.min(current, criteria)} / {criteria}{cfg.targetChoreId ? ' chores' : ''}
</span>
{/if}
<span class="stat {p?.state ?? 'pending'}"> <span class="stat {p?.state ?? 'pending'}">
{#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if} {#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if}
</span> </span>
@@ -577,8 +593,7 @@
{/if} {/if}
<!-- STEP 1 — Who (basics) --> <!-- STEP 1 — Who (basics) -->
{#if step === 1} <div class="step-body" hidden={step !== 1}>
<div class="step-body">
<label> <label>
<span class="field-label">{@html stepIcon(1)} Name</span> <span class="field-label">{@html stepIcon(1)} Name</span>
<input name="name" bind:value={editVals.name} required /> <input name="name" bind:value={editVals.name} required />
@@ -607,18 +622,16 @@
</label> </label>
{/if} {/if}
</div> </div>
{/if}
<!-- STEP 2 — How (rules + progress) --> <!-- STEP 2 — How (rules + progress) -->
{#if step === 2} <div class="step-body" hidden={step !== 2}>
<div class="step-body">
<label> <label>
<span class="field-label"><Settings2 size={14} /> Type</span> <span class="field-label"><Settings2 size={14} /> Type</span>
<select name="type" bind:value={editVals.type}> <select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold</option> <option value="threshold">Threshold</option>
<option value="count">Count</option> <option value="count">Count - (chores)</option>
<option value="manual">Manual</option> <option value="manual">Manual</option>
</select> </select>
</label> </label>
{#if editVals.type === 'threshold'} {#if editVals.type === 'threshold'}
<label> <label>
@@ -661,6 +674,23 @@
</label> </label>
{/if} {/if}
{/if} {/if}
{#if editVals.type === 'count'}
<label>
<span class="field-label"><Target size={14} /> Target Chore</span>
<select name="targetChoreId" bind:value={editVals.targetChoreId}>
<option value="">All Chores</option>
{#each targetChoreOptions as ac}
<option value={ac.id}>
{ac.customName || famStore.templateMap().get(ac.templateId || '')?.name || 'Chore'}
</option>
{/each}
</select>
<span class="hint"
>Count completions of a specific chore only. Leave as "All Chores" to count
every assigned chore.</span
>
</label>
{/if}
<label> <label>
<span class="field-label"><Settings2 size={14} /> Occurrence</span> <span class="field-label"><Settings2 size={14} /> Occurrence</span>
<select name="occurrence" bind:value={editVals.occurrence}> <select name="occurrence" bind:value={editVals.occurrence}>
@@ -724,11 +754,9 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<!-- STEP 3 — What (reward) --> <!-- STEP 3 — What (reward) -->
{#if step === 3} <div class="step-body" hidden={step !== 3}>
<div class="step-body">
<label> <label>
<span class="field-label"> <span class="field-label">
{#if editVals.rewardType === 'cash'} {#if editVals.rewardType === 'cash'}
@@ -805,7 +833,6 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<!-- Footer actions --> <!-- Footer actions -->
<div class="modal-actions"> <div class="modal-actions">
@@ -1135,6 +1162,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.count-progress {
font-size: 0.72rem;
color: #6b7280;
font-weight: 600;
white-space: nowrap;
}
.bar-wrap { .bar-wrap {
flex: 1; flex: 1;
height: 8px; height: 8px;
+2 -4
View File
@@ -277,8 +277,7 @@ export const actions: Actions = {
rewardType: (fd.get('rewardType') as string) || 'points', rewardType: (fd.get('rewardType') as string) || 'points',
rewardValue: (fd.get('rewardValue') as string) || '', rewardValue: (fd.get('rewardValue') as string) || '',
criteriaValue: Number(fd.get('criteriaValue') || 0), criteriaValue: Number(fd.get('criteriaValue') || 0),
period: (fd.get('period') as string) || 'weekly', period: (fd.get('period') as string) || 'weekly'
isPocketMoney: fd.get('isPocketMoney') === 'true'
}); });
} }
return { success: true }; return { success: true };
@@ -322,8 +321,7 @@ export const actions: Actions = {
rewardType: (fd.get('rewardType') as string) || 'points', rewardType: (fd.get('rewardType') as string) || 'points',
rewardValue: (fd.get('rewardValue') as string) || '', rewardValue: (fd.get('rewardValue') as string) || '',
criteriaValue: Number(fd.get('criteriaValue') || 0), criteriaValue: Number(fd.get('criteriaValue') || 0),
period: (fd.get('period') as string) || 'weekly', period: (fd.get('period') as string) || 'weekly'
isPocketMoney: fd.get('isPocketMoney') === 'true'
}); });
} }
return { success: true }; return { success: true };
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -45,9 +45,9 @@ export async function POST({ request, url }) {
await resend.emails.send({ await resend.emails.send({
from: 'no-reply@famchamp.ai', from: 'no-reply@famchamp.ai',
to: email, to: email,
subject: 'Reset your FamChore password', subject: 'Reset your FamDone password',
html: ` html: `
<p>You requested a password reset for your FamChore account.</p> <p>You requested a password reset for your FamDone account.</p>
<p><a href="${resetUrl}">Click here to reset your password</a></p> <p><a href="${resetUrl}">Click here to reset your password</a></p>
<p>This link expires in 1 hour.</p> <p>This link expires in 1 hour.</p>
<p>If you didn't request this, you can ignore this email.</p> <p>If you didn't request this, you can ignore this email.</p>
+34
View File
@@ -0,0 +1,34 @@
import { fail, redirect } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp';
import { slugify, handle } from '@shared/slugify';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
export const actions = {
default: async (event) => {
const fd = await event.request.formData();
const famName = (fd.get('family') || '').toString().trim();
const name = (fd.get('name') || '').toString().trim();
const otp = (fd.get('otp') || '').toString().trim();
if (!famName) return fail(400, { error: 'Enter your family name.', famName, name, otp });
if (!name) return fail(400, { error: 'Enter your name.', famName, name, otp });
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp });
const famSlug = slugify(famName);
try {
const token = await redeemOtp({ famSlug, username: name, otp });
clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
} catch (e) {
return fail(400, {
error: e instanceof Error ? e.message : 'Join failed',
famName,
name,
otp
});
}
const handleName = handle(name);
throw redirect(303, `/${famSlug}/${encodeURIComponent(handleName)}`);
}
};
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components';
import { homeIcon } from '$lib/components/icons';
let name = $state('');
let family = $state('');
let otp = $state('');
let { form } = $props();
</script>
<svelte:head><title>Join a family</title></svelte:head>
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
<span class="home-badge">{@html homeIcon}</span>
</div>
<h1 class="text-xl font-bold text-slate-900">Join the family</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your family name and the code your parent gave you to get started.
</p>
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
family = (result.data as any)?.family || '';
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="family"
bind:value={family}
placeholder="Family name"
autocomplete="organization"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
</section>
</main>
<style>
.home-badge :global(svg) {
width: 1.75rem;
height: 1.75rem;
}
</style>
+9
View File
@@ -1 +1,10 @@
@import 'tailwindcss'; @import 'tailwindcss';
@font-face {
font-family: 'Chewy';
src: url('/fonts/Chewy/chewy-regular.woff2') format('woff2');
}
@theme {
--font-tertiary: 'Chewy', system-ui, sans-serif;
}
+55 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import AuthShell from '$lib/components/AuthShell.svelte'; import AuthShell from '$lib/components/AuthShell.svelte';
import { readShortcut } from '$lib/shortcut';
let { form } = $props(); let { form } = $props();
let email = $state(''); let email = $state('');
@@ -9,6 +10,12 @@
let forgotLoading = $state(false); let forgotLoading = $state(false);
let forgotSuccess = $state(false); let forgotSuccess = $state(false);
// Child sign-in target: jump straight to the fam join page when we know the
// family from a previous session; otherwise fall back to the root /join
// picker where the family is entered by name.
const shortcut = typeof window !== 'undefined' ? readShortcut() : null;
const childJoinHref = shortcut?.famSlug ? `/${shortcut.famSlug}/join` : '/join';
async function handleForgot() { async function handleForgot() {
if (!forgotEmail || forgotLoading) return; if (!forgotEmail || forgotLoading) return;
forgotLoading = true; forgotLoading = true;
@@ -27,12 +34,17 @@
} }
</script> </script>
<AuthShell title="Log in" subtitle="Welcome back to FamChore."> <AuthShell
title="Log in"
subtitle="Welcome back to FamDone."
secondary={secondaryChild}
>
{#if showForgot} {#if showForgot}
{#if forgotSuccess} {#if forgotSuccess}
<p class="form-ok"> <p class="form-ok">
If an account exists for <strong>{forgotEmail}</strong>, a reset link is on its way. If an account exists for <strong>{forgotEmail}</strong>, a reset link is on its way.
</p> </p>
<button <button
class="row-btn centered" class="row-btn centered"
type="button" type="button"
@@ -86,10 +98,19 @@
<p class="alt"> <p class="alt">
Don't have a family yet? <a href="/signup">Create one</a> Don't have a family yet? <a href="/signup">Create one</a>
</p> </p>
<p>Trying to join as a child? Ask your parent to issue your QR code.</p>
{/if} {/if}
</AuthShell> </AuthShell>
{#snippet secondaryChild()}
<div class="child-card">
<div>
<p class="child-title">Signing in as a child?</p>
<p class="child-hint">Join your family with a code from your parent.</p>
</div>
<a class="child-cta" href={childJoinHref}>Join as a child</a>
</div>
{/snippet}
<style> <style>
form { form {
display: grid; display: grid;
@@ -178,4 +199,36 @@
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
} }
.child-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.child-title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: #374151;
}
.child-hint {
margin: 0.2rem 0 0;
font-size: 0.82rem;
color: #6b7280;
}
.child-cta {
display: inline-block;
white-space: nowrap;
background: #f5f3ff;
color: #4338ca;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
padding: 0.55rem 0.9rem;
border-radius: 8px;
border: 1px solid #ddd6fe;
}
.child-cta:hover {
background: #ede9fe;
}
</style> </style>
+3 -2
View File
@@ -5,6 +5,7 @@
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public'; import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js'; import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
import { Button, PricingPlans } from '$lib/components'; import { Button, PricingPlans } from '$lib/components';
import FamDone from '$lib/components/FamDone.svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import { homeIcon, chevronLeftIcon, chevronRightIcon } from '$lib/components/icons'; import { homeIcon, chevronLeftIcon, chevronRightIcon } from '$lib/components/icons';
@@ -56,12 +57,12 @@
} }
</script> </script>
<svelte:head><title>Pricing — FamChore</title></svelte:head> <svelte:head><title>Pricing — FamDone</title></svelte:head>
<div class="shell"> <div class="shell">
<header class="topbar"> <header class="topbar">
<a class="brand" href="/" <a class="brand" href="/"
><span class="brand-icon">{@html homeIcon}</span> <strong>FamChore</strong></a ><span class="brand-icon">{@html homeIcon}</span> <strong><FamDone /></strong></a
> >
<nav> <nav>
<a href="/login" class="nav-link">Log in</a> <a href="/login" class="nav-link">Log in</a>
Binary file not shown.
+15 -15
View File
@@ -1,17 +1,17 @@
{ {
"name": "famchamp-monorepo", "name": "famdone-monorepo",
"private": true, "private": true,
"packageManager": "pnpm@10.30.3", "packageManager": "pnpm@10.30.3",
"scripts": { "scripts": {
"dev": "pnpm --filter frontend dev", "dev": "pnpm --filter frontend dev",
"start": "pnpm dev", "start": "pnpm dev",
"build": "pnpm --filter frontend build", "build": "pnpm --filter frontend build",
"stripe:listen": "stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/api/webhooks/stripe" "stripe:listen": "stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/api/webhooks/stripe"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"esbuild" "esbuild"
] ]
}, },
"version": "1.8.7" "version": "1.9.1"
} }
+3 -1
View File
@@ -228,6 +228,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
rel("memberId", ids.users), rel("memberId", ids.users),
select("period", ["schedule", "daily", "weekly", "monthly"]), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true), select("status", ["active", "completed"], true),
text("targetChoreId"),
bool("isPocketMoney"), bool("isPocketMoney"),
// Standalone/one-off reward progress window. `period` stays empty for // Standalone/one-off reward progress window. `period` stays empty for
// these (they're measured once, not per-cycle). `completeBy` bounds how // these (they're measured once, not per-cycle). `completeBy` bounds how
@@ -344,7 +345,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
build: (ids) => build: (ids) =>
col("assigned_chores", [ col("assigned_chores", [
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.users, true), rel("memberId", ids.users, false), // null = shared with all members
bool("shared"), // true = initially shared (tracked for revoke)
rel("templateId", ids.chore_templates), rel("templateId", ids.chore_templates),
select("frequency", ["daily", "weekly"], true), select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money", "emoji"], true), select("type", ["points", "money", "emoji"], true),