Compare commits
9 Commits
87c28cb6ce
...
2fbf249bfb
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fbf249bfb | |||
| b13de60b52 | |||
| ed31a6e25d | |||
| 68a0851467 | |||
| 8cec99715a | |||
| 1360799b60 | |||
| eaa5104817 | |||
| f288df5b9c | |||
| 1d4d6be20b |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# FamChore v2 — AI Agent Reference
|
# FamDone v2 — AI Agent Reference
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# FamChore v2 — Architecture & Developer Reference
|
# FamDone v2 — Architecture & Developer Reference
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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.)
|
||||||
|
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
<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 |
@@ -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>
|
||||||
@@ -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"
|
||||||
|
>© {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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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'` }),
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 & 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 & 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user