Compare commits
12 Commits
84b3cd48f4
...
f491dbfbf1
| Author | SHA1 | Date | |
|---|---|---|---|
| f491dbfbf1 | |||
| fdacfc7e5c | |||
| a521edaabf | |||
| 11a09d65ff | |||
| 3bf78d777c | |||
| cda2cbfcab | |||
| 852f9edb96 | |||
| 035dbb829a | |||
| 5165c59494 | |||
| d303eda038 | |||
| bcdd3805d5 | |||
| 9f335db7b2 |
@@ -321,6 +321,29 @@
|
|||||||
- **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → Invites → **Account** (Access + Subscription) → App last.
|
- **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → Invites → **Account** (Access + Subscription) → App last.
|
||||||
- **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes.
|
- **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes.
|
||||||
|
|
||||||
|
### 2026-08-31 — All-time earnings + weekly trend chart (family root), lifetime wallet (member)
|
||||||
|
|
||||||
|
- **New server stats service** `frontend/src/lib/server/services/stats.ts` (`familyStats(pb, famId)`, exposed as `s.stats.family(famId)`): scans full-history `completions` + `rewards` once, filtered/summed **on the server**, returns fixed-size `{ allTimePts, allTimeCash, allTimeChores, series }` (per-member weekly buckets `{weekStart, points, cash, chores}`). Server-side aggregation keeps the client payload constant-size regardless of history depth — this is the chosen scaling win for all-time totals + the retrospective graph.
|
||||||
|
- **Totals definition (matches the app's own accounting, no double count):** lifetime points = points-type completions' chore values + **claimed** points rewards; lifetime cash = money-type completions' chore values + **claimed** cash rewards; chores = count of all completions. Unclaimed/requested rewards stay out of totals (they're the "to chase" list).
|
||||||
|
- **Reading `weekly_history` NOT needed** — totals derive from live `completions`+`rewards` (both have `listRule: ""` in SCHEMA_PLAN = open to any authenticated session, which is why parent/child token reads already work today).
|
||||||
|
- **Member wallet** (`[fam]/[username]/+page.svelte`): the existing `allTimeCash`/`allTimePoints` deriveds now include chore completion earnings (previously claimed-rewards only). Wallet UI restyled: big lifetime cells (cash "earned all time" + points "accrued") on top, then a "To collect from parent" divider + the existing pendingRewards list (unclaimed/requested/payday-locked) so members still see what to chase.
|
||||||
|
- **Family root** (`[fam]/+page.svelte` + `+page.server.ts`): load now fetches `familyStats` (+ drops the removed templates). ViewHeader gained an optional default slot (`children?: Snippet`); the fam hero renders large all-time points | cash inside the header. Chores card shows "this week | total all-time" with `justify-content: space-between`. Templates card removed. Chart.js line graph "Over time" plots per-member lines with a Points/Cash/Chores tab; window starts at the current month and expands a month at a time up to 3 months based on data span (≤31d→1, ≤62d→2, else 3).
|
||||||
|
- **Typecheck/build:** still 6 pre-existing canary errors only (none in edited files); `pnpm build` (adapter-node) clean. New code is picked up by HMR; no schema/migration change so no restart required.
|
||||||
|
|
||||||
|
### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented
|
||||||
|
|
||||||
|
- **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled *all* completions, and the child dashboard forced `cfg.period || 'weekly'`.
|
||||||
|
- **Fix**: added to `bonus_configs` (schema + `ensureBonusFields` idempotent field-add in migrate.ts, so existing installs upgrade without wiping): `completeBy` (select `unlimited|week|custom`), `startDate` (text), `completeByDate` (text). New shared helpers in `shared/timezone.ts`: `bonusWindow(cfg, payday, tz)` + `completionInWindow(c, {from,to})` ('' = unbounded side).
|
||||||
|
- **Window semantics** (unified across server `progress()`/`evaluateFam()` and the child `thresholdGoals`): recurring configs (period set) keep their period window; standalone configs use `completeBy`:
|
||||||
|
- `unlimited` (default) → `[startDate, ∞]` cumulative
|
||||||
|
- `week` → current week `[weekStart, weekEnd]`
|
||||||
|
- `custom` → `[startDate, completeByDate]`
|
||||||
|
- No `startDate`/`completeBy` (legacy) → unbounded both sides (== the pre-fix admin behaviour, so existing cash bonuses stay correct).
|
||||||
|
- **UI**: rewards modal gained a "Progress counts" group (shown when no period / not manual): `completeBy` radios (Unlimited | Until a date) + optional `completeByDate` date input + "Progress starts on" `startDate` date input. Creating from a template's `startMode` (Today/Next week) now sets `startDate` (Today = fam-tz today; Next week = +7 days). `openEditConfig`/`openCreateFromTemplate` default `startDate` to today, so saving an existing cash bonus re-scopes it to count from today (progress since the reward begins), not since records started.
|
||||||
|
- **Server actions** (`rewards/+page.server.ts`): `createConfig`/`updateConfig`/`createFromTemplate` now persist `completeBy`/`startDate`/`completeByDate`.
|
||||||
|
- **Typecheck/build**: `svelte-check` still at the 6 pre-existing canary errors (chores RewardType/Frequency, qrcode decl, settings unknown→string) — none in edited files; `pnpm build` (adapter-node) clean.
|
||||||
|
- **Ops note**: the schema fields are added to live PB by `ensureBonusFields()` on next `migrateOnBoot` (dev server restart). A restart of the dev frontend is needed to apply the migration + load the new code.
|
||||||
|
|
||||||
### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX)
|
### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX)
|
||||||
|
|
||||||
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
|
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
|
|
||||||
// Fam-user session: pb_token JWT → authRefresh → locals.user.
|
// Fam-user session: pb_token JWT → authRefresh → locals.user.
|
||||||
const token = event.cookies.get(SESSION_COOKIE);
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const pb = createPbClient(token);
|
const pb = createPbClient(token);
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +49,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
// name/role/famId, since PB doesn't embed custom fields in the
|
// name/role/famId, since PB doesn't embed custom fields in the
|
||||||
// token itself.
|
// token itself.
|
||||||
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
||||||
|
|
||||||
event.locals.user = {
|
event.locals.user = {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.name || record.username || '',
|
name: record.name || record.username || '',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
|
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
|
||||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
href?: string;
|
href?: string;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
@@ -46,4 +46,7 @@
|
|||||||
|
|
||||||
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||||
.btn-danger:hover { background: #b91c1c; }
|
.btn-danger:hover { background: #b91c1c; }
|
||||||
|
|
||||||
|
.btn-success { background: #059669; color: #fff; border-color: #059669; }
|
||||||
|
.btn-success:hover { background: #047857; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { cols = 3, children }: { cols?: number; children?: any } = $props();
|
let { stackTablet = false, children }: { stackTablet?: boolean; children?: any } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card-grid" style="--grid-cols: {cols}">
|
<div class="card-grid" class:stack-tablet={stackTablet}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -10,18 +10,22 @@
|
|||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
/* Tablet: settle to 2 columns */
|
/* Tablet: two columns */
|
||||||
@media (min-width: 640px) and (max-width: 1023px) {
|
@media (min-width: 640px) and (max-width: 1023px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
--grid-cols: 2;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
/* Opt-in: stack to a single column on tablet */
|
||||||
|
.card-grid.stack-tablet {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Mobile: single column */
|
/* Mobile: single column */
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
--grid-cols: 1;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
|
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
|
||||||
|
import {
|
||||||
|
checkCircleIcon,
|
||||||
|
alertTriangleIcon,
|
||||||
|
xCircleIcon,
|
||||||
|
infoIcon
|
||||||
|
} from '$lib/components/icons';
|
||||||
|
|
||||||
function icon(type: NoticeType) {
|
function icon(type: NoticeType) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success': return '✅';
|
case 'success': return checkCircleIcon;
|
||||||
case 'warning': return '⚠️';
|
case 'warning': return alertTriangleIcon;
|
||||||
case 'error': return '❌';
|
case 'error': return xCircleIcon;
|
||||||
default: return 'ℹ️';
|
default: return infoIcon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
{#each notices.list as notice (notice.id)}
|
{#each notices.list as notice (notice.id)}
|
||||||
<div class="notice notice-{notice.type}">
|
<div class="notice notice-{notice.type}">
|
||||||
<div class="notice-content">
|
<div class="notice-content">
|
||||||
<div class="notice-icon">{icon(notice.type)}</div>
|
<div class="notice-icon">{@html icon(notice.type)}</div>
|
||||||
<div class="notice-text">
|
<div class="notice-text">
|
||||||
<h4>{notice.title}</h4>
|
<h4>{notice.title}</h4>
|
||||||
{#if notice.message}
|
{#if notice.message}
|
||||||
@@ -73,6 +79,14 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.notice-icon :global(svg) {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
.notice-success .notice-icon { color: #059669; }
|
||||||
|
.notice-warning .notice-icon { color: #d97706; }
|
||||||
|
.notice-error .notice-icon { color: #dc2626; }
|
||||||
|
.notice-info .notice-icon { color: #6366f1; }
|
||||||
.notice-text h4 {
|
.notice-text h4 {
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { checkCircleIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
interface Tier {
|
interface Tier {
|
||||||
id: 'trial' | 'monthly' | 'yearly';
|
id: 'trial' | 'monthly' | 'yearly';
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{#each tier.features as f}
|
{#each tier.features as f}
|
||||||
<li><span class="tick">✓</span>{f}</li>
|
<li><span class="tick">{@html checkCircleIcon}</span>{f}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
@@ -202,6 +203,7 @@
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
.tick { color: #059669; font-weight: 700; flex-shrink: 0; }
|
.tick { color: #059669; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.tick :global(svg) { width: 1.05em; height: 1.05em; vertical-align: -0.15em; }
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import {
|
import {
|
||||||
dashboardIcon,
|
dashboardIcon,
|
||||||
choresIcon,
|
choresIcon,
|
||||||
rewardsIcon,
|
archiveIcon,
|
||||||
bonusesIcon,
|
gemIcon,
|
||||||
homeIcon,
|
homeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
logoutIcon,
|
logoutIcon,
|
||||||
@@ -13,15 +13,9 @@
|
|||||||
|
|
||||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
||||||
|
|
||||||
let collapsed = $state(false);
|
|
||||||
|
|
||||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||||
let memberName = $derived(session?.memberName || page.params.username || '');
|
let memberName = $derived(session?.memberName || page.params.username || '');
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
collapsed = !collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let showChildItems = $derived(!!memberName);
|
let showChildItems = $derived(!!memberName);
|
||||||
|
|
||||||
let navItems = $derived(
|
let navItems = $derived(
|
||||||
@@ -30,8 +24,8 @@
|
|||||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
|
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: gemIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
|
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: archiveIcon }
|
||||||
]
|
]
|
||||||
: showChildItems
|
: showChildItems
|
||||||
? [
|
? [
|
||||||
@@ -49,28 +43,24 @@
|
|||||||
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
href: session ? '/logout' : '/login',
|
href: session ? '/logout' : role === 'child' ? `/${famSlug}/join` : '/login',
|
||||||
label: session ? 'Log out' : 'Log in',
|
label: session ? 'Log out' : 'Log in',
|
||||||
icon: logoutIcon
|
icon: logoutIcon
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar" class:collapsed>
|
<aside class="sidebar">
|
||||||
<button class="toggle-btn" onclick={toggle}>
|
|
||||||
{collapsed ? '☰' : '✕'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<span class="app-icon">✦</span>
|
<span class="app-icon">✦</span>
|
||||||
{#if !collapsed}<span class="app-name">FamChore</span>{/if}
|
<span class="app-name">FamChore</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
||||||
{@html item.icon}
|
{@html item.icon}
|
||||||
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
|
<span class="nav-label">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -79,7 +69,7 @@
|
|||||||
{#each footerItems as item}
|
{#each footerItems as item}
|
||||||
<a href={item.href} class="nav-item">
|
<a href={item.href} class="nav-item">
|
||||||
{@html item.icon}
|
{@html item.icon}
|
||||||
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
|
<span class="nav-label">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,21 +90,7 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
.toggle-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #a5b4fc;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -171,4 +147,25 @@
|
|||||||
.nav-label {
|
.nav-label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
.sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
transition: left 0.2s;
|
transition: left 0.2s;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.topnav {
|
||||||
|
left: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.topnav-left { display: flex; align-items: center; gap: 0.5rem; }
|
.topnav-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.topnav-announcement { flex: 1; text-align: center; }
|
.topnav-announcement { flex: 1; text-align: center; }
|
||||||
.announcement-text { font-size: 0.85rem; color: #6b7280; }
|
.announcement-text { font-size: 0.85rem; color: #6b7280; }
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, subtitle, hero = false, tabs, weeknav, sort }: {
|
import type { Snippet } from 'svelte';
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
hero = false,
|
||||||
|
tabs,
|
||||||
|
weeknav,
|
||||||
|
sort,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
hero?: boolean;
|
hero?: boolean;
|
||||||
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
|
tabs?: {
|
||||||
|
items: { label: string; value: string }[];
|
||||||
|
active: string;
|
||||||
|
onchange: (v: string) => void;
|
||||||
|
};
|
||||||
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
|
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
|
||||||
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
|
sort?: {
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
active: string;
|
||||||
|
onchange: (v: string) => void;
|
||||||
|
};
|
||||||
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="view-header" class:hero>
|
<div class="view-header flex justify-between" class:hero>
|
||||||
<div class="view-title-group">
|
<div class="view-title-group">
|
||||||
<h1 class="view-title">{title}</h1>
|
<h1 class="view-title text-3xl">{title}</h1>
|
||||||
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
|
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
<div class="view-tools">
|
<div class="view-tools">
|
||||||
{#if tabs}
|
{#if tabs}
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
@@ -22,8 +42,8 @@
|
|||||||
<button
|
<button
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
class:active={tab.value === tabs.active}
|
class:active={tab.value === tabs.active}
|
||||||
onclick={() => tabs.onchange(tab.value)}
|
onclick={() => tabs.onchange(tab.value)}>{tab.label}</button
|
||||||
>{tab.label}</button>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -37,7 +57,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if sort}
|
{#if sort}
|
||||||
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}>
|
<select
|
||||||
|
class="sort-select"
|
||||||
|
value={sort.active}
|
||||||
|
onchange={(e) => sort.onchange(e.currentTarget.value)}
|
||||||
|
>
|
||||||
{#each sort.options as opt}
|
{#each sort.options as opt}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -47,27 +71,80 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.view-header { margin-bottom: 1.5rem; }
|
.view-header {
|
||||||
.view-title-group { margin-bottom: 0.5rem; }
|
margin-bottom: 1.5rem;
|
||||||
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; }
|
}
|
||||||
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; }
|
.view-title-group {
|
||||||
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
margin-bottom: 0.5rem;
|
||||||
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; }
|
}
|
||||||
|
.view-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.view-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
}
|
||||||
|
.view-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px;
|
padding: 0.35rem 0.85rem;
|
||||||
font-size: 0.85rem; color: #6b7280; cursor: pointer;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.week-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
||||||
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px;
|
padding: 0.2rem 0.5rem;
|
||||||
cursor: pointer; font-size: 1rem; line-height: 1;
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.nav-btn:hover { background: #f3f4f6; }
|
|
||||||
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
|
|
||||||
.sort-select {
|
.sort-select {
|
||||||
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px;
|
padding: 0.3rem 0.6rem;
|
||||||
font-size: 0.85rem; background: #fff;
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hero lead (child-dashboard style) ── */
|
/* ── Hero lead (child-dashboard style) ── */
|
||||||
@@ -78,15 +155,41 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
|
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
|
||||||
}
|
}
|
||||||
.view-header.hero .view-title-group { margin-bottom: 0.25rem; }
|
.view-header.hero .view-title-group {
|
||||||
.view-header.hero .view-title { color: #fff; }
|
margin-bottom: 0.25rem;
|
||||||
.view-header.hero .view-subtitle { color: rgba(255, 255, 255, 0.85); }
|
}
|
||||||
.view-header.hero .view-tools { margin-top: 0.75rem; }
|
.view-header.hero .view-title {
|
||||||
.view-header.hero .tab-bar { background: rgba(255, 255, 255, 0.16); }
|
color: #fff;
|
||||||
.view-header.hero .tab-btn { color: rgba(255, 255, 255, 0.9); }
|
}
|
||||||
.view-header.hero .tab-btn:hover { color: #fff; }
|
.view-header.hero .view-subtitle {
|
||||||
.view-header.hero .tab-btn.active { background: #fff; color: #4338ca; }
|
color: rgba(255, 255, 255, 0.85);
|
||||||
.view-header.hero .nav-btn { background: rgba(255, 255, 255, 0.92); border-color: transparent; color: #4338ca; }
|
}
|
||||||
.view-header.hero .nav-label { color: #fff; }
|
.view-header.hero .view-tools {
|
||||||
.view-header.hero .sort-select { background: rgba(255, 255, 255, 0.92); color: #1e1b4b; }
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-bar {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.view-header.hero .nav-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.view-header.hero .nav-label {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.view-header.hero .sort-select {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1e1b4b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
|
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
|
||||||
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
|
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
|
||||||
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
|
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
|
||||||
export const bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
export const moneyIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'
|
||||||
|
export const coinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>'
|
||||||
|
export const walletIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>'
|
||||||
|
export const circleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
|
||||||
|
export const handCoinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>'
|
||||||
|
export const gemIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>'
|
||||||
|
export const archiveIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>'
|
||||||
|
export const todoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>'
|
||||||
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
|
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
|
||||||
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
|
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
|
||||||
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
|
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
|
||||||
@@ -11,3 +18,16 @@ export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fil
|
|||||||
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
|
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
|
||||||
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
|
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
|
||||||
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
||||||
|
export const checkCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'
|
||||||
|
export const revokeIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
|
||||||
|
export const targetIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>'
|
||||||
|
export const calendarIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'
|
||||||
|
export const sunIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||||
|
export const lockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'
|
||||||
|
export const clockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
export const giftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>'
|
||||||
|
export const chevronLeftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 6 -6 6 6 6"/></svg>'
|
||||||
|
export const chevronRightIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m9 6 6 6 -6 6"/></svg>'
|
||||||
|
export const alertTriangleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||||
|
export const xCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>'
|
||||||
|
export const infoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>'
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ async function createRecord(collection: string, data: any): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed two default seasons (Holidays, Term time) per family so the family-admin
|
// Seed a default season (Holidays) per family so the family-admin season
|
||||||
// season picker always has options. Idempotent by name.
|
// picker always has at least one option. "Term time" is intentionally NOT
|
||||||
|
// auto-seeded — it's a UK-school-specific term that not every family wants,
|
||||||
|
// and force-seeding it meant a deleted "Term time" kept coming back on every
|
||||||
|
// boot. Families that want it can add it themselves.
|
||||||
|
// Only seeds when a family has zero seasons, so user deletions stick.
|
||||||
async function ensureDefaultSeasons(): Promise<void> {
|
async function ensureDefaultSeasons(): Promise<void> {
|
||||||
const famsCol = await getCollection("fams");
|
const famsCol = await getCollection("fams");
|
||||||
if (!famsCol) return;
|
if (!famsCol) return;
|
||||||
@@ -78,18 +82,19 @@ async function ensureDefaultSeasons(): Promise<void> {
|
|||||||
headers: { Authorization: `Bearer ${t}` },
|
headers: { Authorization: `Bearer ${t}` },
|
||||||
});
|
});
|
||||||
const fams = (await famsRes.json()).items || [];
|
const fams = (await famsRes.json()).items || [];
|
||||||
const defaults = [
|
const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }];
|
||||||
{ name: "Holidays", color: "#f59e0b", active: true },
|
|
||||||
{ name: "Term time", color: "#3b82f6", active: true },
|
|
||||||
];
|
|
||||||
for (const fam of fams) {
|
for (const fam of fams) {
|
||||||
const sRes = await fetch(
|
const sRes = await fetch(
|
||||||
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
|
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
|
||||||
{ headers: { Authorization: `Bearer ${t}` } }
|
{ headers: { Authorization: `Bearer ${t}` } }
|
||||||
);
|
);
|
||||||
const have = new Set(((await sRes.json()).items || []).map((s: any) => s.name));
|
const have = ((await sRes.json()).items || []) as any[];
|
||||||
|
// Only seed defaults for families that have no seasons at all. Seeding
|
||||||
|
// per-name (idempotent) forced a deleted default (e.g. "Term time") back
|
||||||
|
// on every boot; now a user who removes one keeps it removed as long as
|
||||||
|
// at least one season remains.
|
||||||
|
if (have.length === 0) {
|
||||||
for (const d of defaults) {
|
for (const d of defaults) {
|
||||||
if (!have.has(d.name)) {
|
|
||||||
await createRecord("seasons", { famId: fam.id, ...d });
|
await createRecord("seasons", { famId: fam.id, ...d });
|
||||||
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
|
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
|
||||||
}
|
}
|
||||||
@@ -435,6 +440,25 @@ async function ensureBonusFields(): Promise<void> {
|
|||||||
fields.push({ name: "isPocketMoney", type: "bool", required: false });
|
fields.push({ name: "isPocketMoney", type: "bool", required: false });
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
// Standalone-reward progress window (bonus_configs only): how far progress
|
||||||
|
// counts (`completeBy`) and the window's start/custom-end dates.
|
||||||
|
if (name === "bonus_configs") {
|
||||||
|
if (!has("completeBy")) {
|
||||||
|
fields.push({
|
||||||
|
name: "completeBy", type: "select", required: false,
|
||||||
|
values: ["unlimited", "week", "custom"], maxSelect: 1,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!has("startDate")) {
|
||||||
|
fields.push({ name: "startDate", type: "text", required: false });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!has("completeByDate")) {
|
||||||
|
fields.push({ name: "completeByDate", 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) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
nextPaydayAfter,
|
nextPaydayAfter,
|
||||||
weekStart
|
weekStart,
|
||||||
|
bonusWindow,
|
||||||
|
completionInWindow
|
||||||
} from '@shared/timezone';
|
} from '@shared/timezone';
|
||||||
import { famMeta } from './fam';
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
@@ -55,13 +57,8 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
// 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 pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : '';
|
const win = bonusWindow(cfg, paydayEval, tzEval);
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
|
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
||||||
const periodCompletions = cfg.period
|
|
||||||
? allCompletions.filter(
|
|
||||||
(c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd
|
|
||||||
)
|
|
||||||
: allCompletions;
|
|
||||||
|
|
||||||
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,11 +238,10 @@ export async function progress(pb: any, famId: string) {
|
|||||||
|
|
||||||
for (const cfg of configsData) {
|
for (const cfg of configsData) {
|
||||||
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||||
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
const win = bonusWindow(cfg, payday, tz);
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
const pStart = win.from;
|
||||||
const periodCompletions = cfg.period
|
const pEnd = win.to;
|
||||||
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
|
const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
|
||||||
: completionsList;
|
|
||||||
|
|
||||||
const progressRows: any[] = [];
|
const progressRows: any[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,6 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
]);
|
]);
|
||||||
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
let settings: any = {};
|
|
||||||
try {
|
|
||||||
const s = await pb
|
|
||||||
.collection('settings')
|
|
||||||
.getFullList({ filter: `famId = '${famId}'` });
|
|
||||||
settings = s?.[0] || {};
|
|
||||||
} catch {}
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
assigned,
|
assigned,
|
||||||
@@ -25,7 +18,6 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
bonusConfigs,
|
bonusConfigs,
|
||||||
payday,
|
payday,
|
||||||
paydayTime,
|
paydayTime,
|
||||||
timezone: tz,
|
timezone: tz
|
||||||
simulateEow: !!settings.simulateEow
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -11,13 +11,6 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
]);
|
]);
|
||||||
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
let settings: any = {};
|
|
||||||
try {
|
|
||||||
const s = await pb
|
|
||||||
.collection('settings')
|
|
||||||
.getFullList({ filter: `famId = '${famId}'` });
|
|
||||||
settings = s?.[0] || {};
|
|
||||||
} catch {}
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
assigned,
|
assigned,
|
||||||
@@ -26,8 +19,7 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
bonusConfigs,
|
bonusConfigs,
|
||||||
payday,
|
payday,
|
||||||
paydayTime,
|
paydayTime,
|
||||||
timezone: tz,
|
timezone: tz
|
||||||
simulateEow: !!settings.simulateEow
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,149 +172,6 @@ export async function weeklySummary(pb: any, famId: string) {
|
|||||||
return { weekStart: ws, daysInWeek, summaries };
|
return { weekStart: ws, daysInWeek, summaries };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function eowPreview(pb: any, famId: string) {
|
|
||||||
const { payday, tz } = await famMeta(pb, famId);
|
|
||||||
const ws = weekStart(payday, tz);
|
|
||||||
const we = periodEnd('weekly', ws);
|
|
||||||
|
|
||||||
const [members, assigned, completions, configs, rewards] = await Promise.all([
|
|
||||||
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
|
||||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
|
||||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }),
|
|
||||||
pb
|
|
||||||
.collection('bonus_configs')
|
|
||||||
.getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
|
||||||
.catch(() => []),
|
|
||||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => [])
|
|
||||||
]);
|
|
||||||
|
|
||||||
let rewardPointsList: any[] = [];
|
|
||||||
let rewardCashList: any[] = [];
|
|
||||||
try {
|
|
||||||
[rewardPointsList, rewardCashList] = await Promise.all([
|
|
||||||
pb.collection('rewards').getFullList({
|
|
||||||
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
|
||||||
}),
|
|
||||||
pb.collection('rewards').getFullList({
|
|
||||||
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const assignedList = assigned;
|
|
||||||
const completionsList = completions;
|
|
||||||
|
|
||||||
const summaries = members.map((m: any) => {
|
|
||||||
const mc = completionsList.filter((c: any) => c.memberId === m.id);
|
|
||||||
const weekPoints = mc.reduce((sum: number, c: any) => {
|
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
const weekMoney = mc.reduce((sum: number, c: any) => {
|
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (ch?.type === 'money' ? Number(ch.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
const bonusPoints = rewardPointsList
|
|
||||||
.filter((r: any) => r.memberId === m.id)
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
|
||||||
const bonusMoney = rewardCashList
|
|
||||||
.filter((r: any) => r.memberId === m.id)
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
|
||||||
return {
|
|
||||||
memberId: m.id,
|
|
||||||
memberName: m.name || m.username || m.id.slice(0, 6),
|
|
||||||
memberColor: m.color,
|
|
||||||
pointsEarned: weekPoints + bonusPoints,
|
|
||||||
moneyEarned: weekMoney + bonusMoney,
|
|
||||||
choresCompleted: mc.length,
|
|
||||||
bonusEarned: bonusPoints
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const predictedRewards: any[] = [];
|
|
||||||
const existingRewards = rewards;
|
|
||||||
for (const cfg of configs) {
|
|
||||||
if (cfg.type === 'manual') continue;
|
|
||||||
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
|
||||||
const periodCompletions = cfg.period
|
|
||||||
? completionsList.filter(
|
|
||||||
(c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd
|
|
||||||
)
|
|
||||||
: completionsList;
|
|
||||||
const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
|
||||||
|
|
||||||
const tryEval = (sourceComps: any[]) => {
|
|
||||||
if (cfg.type === 'threshold')
|
|
||||||
return sourceComps.reduce((sum: number, c: any) => {
|
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
if (cfg.type === 'count') return sourceComps.length;
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cfg.target === 'individual') {
|
|
||||||
const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members;
|
|
||||||
for (const m of targets) {
|
|
||||||
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
|
||||||
const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id));
|
|
||||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
|
||||||
predictedRewards.push({
|
|
||||||
config: cfg.name,
|
|
||||||
memberName: m.name || m.id.slice(0, 6),
|
|
||||||
type: cfg.rewardType,
|
|
||||||
value: Number(cfg.rewardValue) || 0,
|
|
||||||
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (cfg.target === 'collaborative') {
|
|
||||||
if (cfgRewards.length) continue;
|
|
||||||
const allIds = members.map((m: any) => m.id);
|
|
||||||
const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId));
|
|
||||||
const current = tryEval(teamComps);
|
|
||||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
|
||||||
predictedRewards.push({
|
|
||||||
config: cfg.name,
|
|
||||||
memberName: 'Everyone',
|
|
||||||
type: cfg.rewardType,
|
|
||||||
value: Number(cfg.rewardValue) || 0,
|
|
||||||
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
|
||||||
});
|
|
||||||
} else if (cfg.target === 'competitive') {
|
|
||||||
if (cfgRewards.length) continue;
|
|
||||||
const scored = members.map((m: any) => ({
|
|
||||||
memberId: m.id,
|
|
||||||
name: m.name,
|
|
||||||
current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id))
|
|
||||||
}));
|
|
||||||
const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue));
|
|
||||||
const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0);
|
|
||||||
const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0];
|
|
||||||
if (winner)
|
|
||||||
predictedRewards.push({
|
|
||||||
config: cfg.name,
|
|
||||||
memberName: winner.name,
|
|
||||||
type: cfg.rewardType,
|
|
||||||
value: Number(cfg.rewardValue) || 0,
|
|
||||||
detail: `winner ${winner.current} pts`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextWeekStart = addDaysStr(ws, 7);
|
|
||||||
|
|
||||||
return {
|
|
||||||
simulateEow: true,
|
|
||||||
weekStart: ws,
|
|
||||||
weekEnd: we,
|
|
||||||
nextWeekStart,
|
|
||||||
summaries,
|
|
||||||
predictedRewards,
|
|
||||||
completionsThisWeek: completionsList.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseWeek(pb: any, famId: string) {
|
export async function releaseWeek(pb: any, famId: string) {
|
||||||
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
|
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as chatSvc from './chat';
|
|||||||
import * as settingsSvc from './settings';
|
import * as settingsSvc from './settings';
|
||||||
import * as crudSvc from './crud';
|
import * as crudSvc from './crud';
|
||||||
import * as debugSvc from './debug';
|
import * as debugSvc from './debug';
|
||||||
|
import * as statsSvc from './stats';
|
||||||
|
|
||||||
export * from './chat';
|
export * from './chat';
|
||||||
export { famMeta } from './fam';
|
export { famMeta } from './fam';
|
||||||
@@ -35,7 +36,6 @@ export function createServices(
|
|||||||
...(timezone !== undefined ? { timezone } : {})
|
...(timezone !== undefined ? { timezone } : {})
|
||||||
}),
|
}),
|
||||||
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
|
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
|
||||||
eowPreview: (famId: string) => famSvc.eowPreview(pb, famId),
|
|
||||||
payday: (famId: string) => famSvc.releaseWeek(pb, famId),
|
payday: (famId: string) => famSvc.releaseWeek(pb, famId),
|
||||||
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
|
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
|
||||||
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
|
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
|
||||||
@@ -94,6 +94,9 @@ export function createServices(
|
|||||||
},
|
},
|
||||||
debug: {
|
debug: {
|
||||||
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
|
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
family: (famId: string) => statsSvc.familyStats(pb, famId)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ function resolveServerTz(tz?: string): string {
|
|||||||
export async function getSettings(pb: any, famId: string) {
|
export async function getSettings(pb: any, famId: string) {
|
||||||
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
||||||
const s = list?.[0] || {};
|
const s = list?.[0] || {};
|
||||||
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
|
return { webhookUrl: s.webhookUrl || '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
|
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
|
||||||
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
||||||
const existing = list?.[0];
|
const existing = list?.[0];
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if (data.simulateEow !== undefined) patch.simulateEow = !!data.simulateEow;
|
|
||||||
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
|
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
|
||||||
let s;
|
let s;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -23,5 +22,5 @@ export async function updateSettings(pb: any, famId: string, data: Record<string
|
|||||||
} else {
|
} else {
|
||||||
s = await pb.collection('settings').create({ famId, ...patch });
|
s = await pb.collection('settings').create({ famId, ...patch });
|
||||||
}
|
}
|
||||||
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
|
return { webhookUrl: s.webhookUrl || '' };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { addDaysStr, weekdayInTz, resolveTz } from '@shared/timezone';
|
||||||
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
|
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
|
||||||
|
// parametric form of `weekStart()` so we can bucket historical weeks, not
|
||||||
|
// just the current one.
|
||||||
|
function weekStartFor(dateStr: string, payday: number, tz: string): string {
|
||||||
|
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
|
||||||
|
const back = (((wd - payday) % 7) + 7) % 7;
|
||||||
|
return addDaysStr(dateStr, -back);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeeklyPoint = {
|
||||||
|
weekStart: string;
|
||||||
|
points: number;
|
||||||
|
cash: number;
|
||||||
|
chores: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemberSeries = {
|
||||||
|
memberId: string;
|
||||||
|
memberName: string;
|
||||||
|
memberColor: string;
|
||||||
|
weeks: WeeklyPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FamilyStats = {
|
||||||
|
allTimePts: number;
|
||||||
|
allTimeCash: number;
|
||||||
|
allTimeChores: number;
|
||||||
|
series: MemberSeries[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side lifetime + weekly aggregates. Scans full-history `completions`
|
||||||
|
// and `rewards` once here (filtered and summed on the server) so the client
|
||||||
|
// receives fixed-size pre-computed numbers instead of every historical row —
|
||||||
|
// this is what keeps the all-time totals + retrospective graph scalable.
|
||||||
|
export async function familyStats(pb: any, famId: string): Promise<FamilyStats> {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
|
||||||
|
const [members, assigned, completions, rewards] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assignedMap = new Map<string, any>(assigned.map((a: any) => [a.id, a]));
|
||||||
|
|
||||||
|
const memberPts: Record<string, number> = {};
|
||||||
|
const memberCash: Record<string, number> = {};
|
||||||
|
const memberChores: Record<string, number> = {};
|
||||||
|
for (const m of members) {
|
||||||
|
memberPts[m.id] = 0;
|
||||||
|
memberCash[m.id] = 0;
|
||||||
|
memberChores[m.id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-member weekly buckets (points/cash/chores), keyed by `memberId:weekStart`.
|
||||||
|
const weekMap = new Map<string, WeeklyPoint>();
|
||||||
|
|
||||||
|
function addWeek(memberId: string, weekStart: string, pts: number, cash: number, chores: number) {
|
||||||
|
const key = `${memberId}:${weekStart}`;
|
||||||
|
const w = weekMap.get(key);
|
||||||
|
if (w) {
|
||||||
|
w.points += pts;
|
||||||
|
w.cash += cash;
|
||||||
|
w.chores += chores;
|
||||||
|
} else {
|
||||||
|
weekMap.set(key, { weekStart, points: pts, cash, chores });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chore completions contribute their chore value (points/money) to the
|
||||||
|
// member's lifetime totals and their weekly bucket. Every completion counts
|
||||||
|
// as a chore done (regardless of type).
|
||||||
|
for (const c of completions) {
|
||||||
|
const chore = assignedMap.get(c.assignedChoreId);
|
||||||
|
const pts = chore?.type === 'points' ? Number(chore.value) || 0 : 0;
|
||||||
|
const cash = chore?.type === 'money' ? Number(chore.value) || 0 : 0;
|
||||||
|
const date = (c.date || '').slice(0, 10);
|
||||||
|
memberPts[c.memberId] = (memberPts[c.memberId] || 0) + pts;
|
||||||
|
memberCash[c.memberId] = (memberCash[c.memberId] || 0) + cash;
|
||||||
|
memberChores[c.memberId] = (memberChores[c.memberId] || 0) + 1;
|
||||||
|
if (date) addWeek(c.memberId, weekStartFor(date, payday, tz), pts, cash, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money-type todos auto-create a matching cash reward when the todo is
|
||||||
|
// completed (see completions.toggle). That cash is already counted in the
|
||||||
|
// completion loop above, so skip those completion-linked rewards here to
|
||||||
|
// avoid double-counting the same earning event.
|
||||||
|
const autoRewardIds = new Set(
|
||||||
|
completions.map((c: any) => c.rewardId).filter((id?: string) => !!id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Claimed rewards (bonuses/triggers/pocket money) count toward lifetime
|
||||||
|
// earnings once issued. Unclaimed/requested stay "to chase" client-side.
|
||||||
|
for (const r of rewards) {
|
||||||
|
if (r.status !== 'claimed') continue;
|
||||||
|
if (autoRewardIds.has(r.id)) continue;
|
||||||
|
const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10);
|
||||||
|
if (r.rewardType === 'points') {
|
||||||
|
const v = Number(r.value) || 0;
|
||||||
|
memberPts[r.memberId] = (memberPts[r.memberId] || 0) + v;
|
||||||
|
if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), v, 0, 0);
|
||||||
|
} else if (r.rewardType === 'cash') {
|
||||||
|
const v = Number(r.value) || 0;
|
||||||
|
memberCash[r.memberId] = (memberCash[r.memberId] || 0) + v;
|
||||||
|
if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), 0, v, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: MemberSeries[] = members.map((m: any) => ({
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name,
|
||||||
|
memberColor: m.color || '#6366f1',
|
||||||
|
weeks: [...weekMap.entries()]
|
||||||
|
.filter(([key]) => key.startsWith(m.id + ':'))
|
||||||
|
.map(([, w]) => w)
|
||||||
|
.sort((a, b) => a.weekStart.localeCompare(b.weekStart))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
allTimePts: Object.values(memberPts).reduce((s, n) => s + n, 0),
|
||||||
|
allTimeCash: Object.values(memberCash).reduce((s, n) => s + n, 0),
|
||||||
|
allTimeChores: Object.values(memberChores).reduce((s, n) => s + n, 0),
|
||||||
|
series
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type Shortcut = {
|
||||||
|
famSlug: string;
|
||||||
|
famName: string;
|
||||||
|
userName: string; // '' for parents (no handle in URL)
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = 'fam_shortcut';
|
||||||
|
|
||||||
|
export function recordShortcut(entry: Shortcut) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readShortcut(): Shortcut | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as Shortcut) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortcutTarget(s: Shortcut): string {
|
||||||
|
return s.userName ? `/${s.famSlug}/${s.userName}` : `/${s.famSlug}`;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ class ChatStore {
|
|||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
private lastSeenAt = 0;
|
private lastSeenAt = 0;
|
||||||
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
private audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
typingNames = $derived.by(() => {
|
typingNames = $derived.by(() => {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -156,8 +157,9 @@ class ChatStore {
|
|||||||
}
|
}
|
||||||
// Stop showing their typing indicator once the message lands.
|
// Stop showing their typing indicator once the message lands.
|
||||||
this.clearTyping(record.authorId);
|
this.clearTyping(record.authorId);
|
||||||
if (record.authorId !== this.actorId && !this.open) {
|
if (record.authorId !== this.actorId) {
|
||||||
this.unread++;
|
if (!this.open) this.unread++;
|
||||||
|
this.playDunk();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,39 @@ class ChatStore {
|
|||||||
if (this.typing[actorId]) this.removeTyping(actorId);
|
if (this.typing[actorId]) this.removeTyping(actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short, low-volume "dunk" blip played when a message from someone else is
|
||||||
|
// broadcast in. Synthesised with the Web Audio API so there's no asset to
|
||||||
|
// ship. Browsers require a user gesture before audio can start, but chat
|
||||||
|
// messages only arrive after the user has interacted, so the context
|
||||||
|
// resumes fine. Failures are silently ignored.
|
||||||
|
private playDunk() {
|
||||||
|
if (typeof window === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const Ctor = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (!Ctor) return;
|
||||||
|
if (!this.audioCtx) this.audioCtx = new Ctor();
|
||||||
|
const ctx = this.audioCtx;
|
||||||
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
// Quick downward pitch sweep gives the soft "dun-nk" thud.
|
||||||
|
osc.frequency.setValueAtTime(190, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(70, now + 0.18);
|
||||||
|
// Low volume with a fast attack/decay envelope.
|
||||||
|
gain.gain.setValueAtTime(0.0001, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||||
|
osc.connect(gain).connect(ctx.destination);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.24);
|
||||||
|
} catch {
|
||||||
|
/* audio is best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private removeTyping(actorId: string) {
|
private removeTyping(actorId: string) {
|
||||||
if (!this.typing[actorId]) return;
|
if (!this.typing[actorId]) return;
|
||||||
const next = { ...this.typing };
|
const next = { ...this.typing };
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ export interface BonusConfig {
|
|||||||
period?: BonusPeriod;
|
period?: BonusPeriod;
|
||||||
status: BonusStatus;
|
status: BonusStatus;
|
||||||
isPocketMoney?: boolean;
|
isPocketMoney?: boolean;
|
||||||
|
completeBy?: 'unlimited' | 'week' | 'custom';
|
||||||
|
startDate?: string;
|
||||||
|
completeByDate?: string;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
@@ -182,7 +185,6 @@ export interface Settings {
|
|||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
simulateEow?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons';
|
||||||
|
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
|
||||||
|
|
||||||
|
let shortcut = $state<(Shortcut & { target: string }) | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const s = readShortcut();
|
||||||
|
if (s) shortcut = { ...s, target: shortcutTarget(s) };
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -11,6 +21,18 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="landing">
|
<main class="landing">
|
||||||
|
{#if shortcut}
|
||||||
|
<a class="return-chip" href={shortcut.target} title="Jump back into {shortcut.famName}">
|
||||||
|
<span class="return-icon">
|
||||||
|
{@html homeIcon}
|
||||||
|
<span class="return-dot"></span>
|
||||||
|
</span>
|
||||||
|
<span class="return-text">
|
||||||
|
<span class="return-fam">{shortcut.famName}</span>
|
||||||
|
<span class="return-sub">Jump back in {@html chevronRightIcon}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-inner">
|
<div class="hero-inner">
|
||||||
@@ -23,9 +45,9 @@
|
|||||||
automatically — no spreadsheets, no nagging.
|
automatically — no spreadsheets, no nagging.
|
||||||
</p>
|
</p>
|
||||||
<ul class="hero-ticks">
|
<ul class="hero-ticks">
|
||||||
<li>Set up in under a minute</li>
|
<li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
|
||||||
<li>Kids join with a simple invite code</li>
|
<li><span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code</li>
|
||||||
<li>Allowance lands on payday, automatically</li>
|
<li><span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, automatically</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,7 +56,7 @@
|
|||||||
<p class="card-sub">Free to get going. Takes about a minute.</p>
|
<p class="card-sub">Free to get going. Takes about a minute.</p>
|
||||||
<a class="submit" href="/signup">Create my family</a>
|
<a class="submit" href="/signup">Create my family</a>
|
||||||
<p class="card-alt">
|
<p class="card-alt">
|
||||||
<a href="/pricing">See pricing →</a>
|
<a href="/pricing">See pricing {@html chevronRightIcon}</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-alt">
|
<p class="card-alt">
|
||||||
Already have a family? <a href="/login">Log in</a>
|
Already have a family? <a href="/login">Log in</a>
|
||||||
@@ -97,7 +119,7 @@
|
|||||||
<div class="cta">
|
<div class="cta">
|
||||||
<p>Ready to make chores painless?</p>
|
<p>Ready to make chores painless?</p>
|
||||||
<a class="cta-btn" href="/signup">Create your family</a>
|
<a class="cta-btn" href="/signup">Create your family</a>
|
||||||
<p class="card-alt"><a href="/pricing">or see pricing →</a></p>
|
<p class="card-alt"><a href="/pricing">or see pricing {@html chevronRightIcon}</a></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -110,6 +132,58 @@
|
|||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RETURNING-USER SHORTCUT */
|
||||||
|
.return-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.45rem 0.9rem 0.45rem 0.55rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.return-chip:hover {
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
box-shadow: 0 2px 10px rgba(67, 56, 202, 0.14);
|
||||||
|
}
|
||||||
|
.return-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.return-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
background: #22c55e;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.return-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.return-fam {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.return-sub {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
/* HERO */
|
/* HERO */
|
||||||
.hero {
|
.hero {
|
||||||
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
||||||
@@ -156,10 +230,20 @@
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
.hero-ticks li::before {
|
.hero-ticks li::before {
|
||||||
content: "✓";
|
content: "";
|
||||||
color: #4338ca;
|
}
|
||||||
font-weight: 700;
|
.hero-ticks .tick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.hero-ticks .tick :global(svg) {
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SIGNUP CARD */
|
/* SIGNUP CARD */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { notices } from '$lib/stores/notices.svelte';
|
import { notices } from '$lib/stores/notices.svelte';
|
||||||
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||||
import { chatIcon } from '$lib/components/icons';
|
import { chatIcon } from '$lib/components/icons';
|
||||||
|
import { recordShortcut } from '$lib/shortcut';
|
||||||
import type { Session } from '$lib/types';
|
import type { Session } from '$lib/types';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
let disabled = $derived(!!data.famAccess?.disabled);
|
let disabled = $derived(!!data.famAccess?.disabled);
|
||||||
let accessReason = $derived(data.famAccess?.reason || '');
|
let accessReason = $derived(data.famAccess?.reason || '');
|
||||||
// Settings stays usable while paused so admins can apply a code / manage billing.
|
// Settings stays usable while paused so admins can apply a code / manage billing.
|
||||||
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
|
let hasAuth = $derived(!!data.session);
|
||||||
|
let locked = $derived((disabled && !page.url.pathname.endsWith('/settings')) || !hasAuth);
|
||||||
|
|
||||||
// ── Post-checkout activation (event-driven) ──
|
// ── Post-checkout activation (event-driven) ──
|
||||||
// Landing with ?checkout=return: if the webhook has already landed we show
|
// Landing with ?checkout=return: if the webhook has already landed we show
|
||||||
@@ -88,10 +90,16 @@
|
|||||||
|
|
||||||
function accessMessage(reason: string, parent: boolean) {
|
function accessMessage(reason: string, parent: boolean) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
code_expired: parent ? 'Access paused — your code has expired. Add a new one.' : 'Access paused.',
|
code_expired: parent
|
||||||
|
? 'Access paused — your code has expired. Add a new one.'
|
||||||
|
: 'Access paused.',
|
||||||
code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.',
|
code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.',
|
||||||
subscription_inactive: parent ? 'Access paused — check your subscription payment.' : 'Access paused.',
|
subscription_inactive: parent
|
||||||
canceled: parent ? 'Access paused — renew your subscription or add a code.' : 'Access paused.',
|
? 'Access paused — check your subscription payment.'
|
||||||
|
: 'Access paused.',
|
||||||
|
canceled: parent
|
||||||
|
? 'Access paused — renew your subscription or add a code.'
|
||||||
|
: 'Access paused.',
|
||||||
no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.'
|
no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.'
|
||||||
};
|
};
|
||||||
return map[reason] || 'Access paused.';
|
return map[reason] || 'Access paused.';
|
||||||
@@ -102,6 +110,19 @@
|
|||||||
let prevClaimedIds = new Set<string>();
|
let prevClaimedIds = new Set<string>();
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
|
// Persist a returning-user shortcut (localStorage) whenever an authenticated
|
||||||
|
// session exists. $effect (not onMount) so it also fires when the session
|
||||||
|
// appears after the layout was already mounted — e.g. a member redeeming
|
||||||
|
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
|
||||||
|
$effect(() => {
|
||||||
|
if (!page.data.session) return;
|
||||||
|
recordShortcut({
|
||||||
|
famSlug: page.params.fam || '',
|
||||||
|
famName: (page.data.fam?.name as string) || page.params.fam || '',
|
||||||
|
userName: page.data.role === 'parent' ? '' : page.params.username || ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!session || !famStore.initialized) return;
|
if (!session || !famStore.initialized) return;
|
||||||
const rewards = famStore.rewards;
|
const rewards = famStore.rewards;
|
||||||
@@ -160,10 +181,13 @@
|
|||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<div class="page-content" class:locked>{@render children()}</div>
|
<div class="page-content" class:locked>{@render children()}</div>
|
||||||
|
|
||||||
{#if locked}
|
{#if locked}
|
||||||
<div class="disabled-overlay">
|
<div class="disabled-overlay">
|
||||||
<div class="disabled-card">
|
<div class="disabled-card">
|
||||||
{#if activating}
|
{#if !hasAuth}
|
||||||
|
<h2>You need to be logged in to view this page</h2>
|
||||||
|
{:else if activating}
|
||||||
<div class="spinner" aria-hidden="true"></div>
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
<strong>Activating your subscription…</strong>
|
<strong>Activating your subscription…</strong>
|
||||||
<span>Payment received — this usually only takes a few seconds.</span>
|
<span>Payment received — this usually only takes a few seconds.</span>
|
||||||
@@ -241,6 +265,11 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
transition: margin-left 0.2s;
|
transition: margin-left 0.2s;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.app-main {
|
||||||
|
margin-left: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.page-wrap {
|
.page-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 70vh;
|
min-height: 70vh;
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ export async function load(event) {
|
|||||||
const s = createServices(pbUser(event), session);
|
const s = createServices(pbUser(event), session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [members, templates, assigned, summary] = await Promise.all([
|
const [members, assigned, summary, fam, stats] = await Promise.all([
|
||||||
s.crud.list('members', famId),
|
s.crud.list('members', famId),
|
||||||
s.crud.list('chore-templates', famId),
|
|
||||||
s.crud.list('assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
s.fam.weeklySummary(famId),
|
s.fam.weeklySummary(famId),
|
||||||
|
s.fam.get(famId),
|
||||||
|
s.stats.family(famId)
|
||||||
]);
|
]);
|
||||||
return { members, templates, assigned, summary };
|
return { members, assigned, summary, fam, stats };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,39 @@
|
|||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import { formatDDMMYY } from '$lib/format';
|
import { formatDDMMYY } from '$lib/format';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
|
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||||
|
|
||||||
|
let famTz = $derived(resolveTz(data.fam?.timezone || (page.data.fam as any)?.timezone || 'auto'));
|
||||||
|
|
||||||
let summary = $state(data.summary);
|
let summary = $state(data.summary);
|
||||||
let members = $state(data.members || []);
|
let members = $state(data.members || []);
|
||||||
let templates = $state(data.templates || []);
|
|
||||||
let assigned = $state(data.assigned || []);
|
let assigned = $state(data.assigned || []);
|
||||||
|
let stats = $state(data.stats || { allTimePts: 0, allTimeCash: 0, allTimeChores: 0, series: [] });
|
||||||
|
|
||||||
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
|
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
|
||||||
let donutCharts: Record<string, Chart> = {};
|
let donutCharts: Record<string, Chart> = {};
|
||||||
let choresCanvas: HTMLCanvasElement | undefined;
|
let choresCanvas: HTMLCanvasElement | undefined;
|
||||||
let choresChart: Chart | undefined;
|
let choresChart: Chart | undefined;
|
||||||
|
let trendCanvas = $state<HTMLCanvasElement | undefined>();
|
||||||
|
let trendChart: Chart | undefined;
|
||||||
|
// Line-graph metric view: cash | points | chores
|
||||||
|
let metric = $state<'cash' | 'points' | 'chores'>('points');
|
||||||
|
|
||||||
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
|
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
|
||||||
donutCanvases[memberId] = node;
|
donutCanvases[memberId] = node;
|
||||||
return { destroy() { delete donutCanvases[memberId]; } };
|
return {
|
||||||
|
destroy() {
|
||||||
|
delete donutCanvases[memberId];
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Derived tile stats ───
|
// ─── Derived tile stats (this week) ───
|
||||||
let tileMembers = $derived(members.length);
|
let tileMembers = $derived(members.length);
|
||||||
let tilePts = $derived(
|
let tilePts = $derived(
|
||||||
summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0
|
summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0
|
||||||
@@ -38,10 +49,109 @@
|
|||||||
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
|
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── All-time header stats (server-computed) ───
|
||||||
|
let allTimePts = $derived(Number(stats.allTimePts) || 0);
|
||||||
|
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
|
||||||
|
let allTimeChores = $derived(Number(stats.allTimeChores) || 0);
|
||||||
|
|
||||||
|
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
|
||||||
|
let trend = $derived.by(() => {
|
||||||
|
const series = Array.isArray(stats.series) ? stats.series : [];
|
||||||
|
const memberLines = series.filter((m: any) => m.memberId);
|
||||||
|
return memberLines;
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeTrend(): { labels: string[]; datasets: any[] } {
|
||||||
|
const allWeeks = new Set<string>();
|
||||||
|
for (const m of trend) {
|
||||||
|
for (const w of m.weeks || []) allWeeks.add(w.weekStart);
|
||||||
|
}
|
||||||
|
const weeks = [...allWeeks].sort();
|
||||||
|
if (weeks.length === 0) return { labels: [], datasets: [] };
|
||||||
|
|
||||||
|
const latest = weeks[weeks.length - 1];
|
||||||
|
const earliest = weeks[0];
|
||||||
|
const spanDays = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((new Date(latest).getTime() - new Date(earliest).getTime()) / 86400000)
|
||||||
|
);
|
||||||
|
// 1 month if data spans <= ~1 month; grow a month at a time up to 3.
|
||||||
|
const months = spanDays <= 31 ? 1 : spanDays <= 62 ? 2 : 3;
|
||||||
|
const windowStart = latest ? addDaysStr(latest, -(months * 31)) : earliest;
|
||||||
|
const labels = weeks.filter((w) => w >= windowStart);
|
||||||
|
|
||||||
|
const valueFor = (m: any, w: string, met: string): number => {
|
||||||
|
const wk = (m.weeks || []).find((x: any) => x.weekStart === w);
|
||||||
|
if (!wk) return 0;
|
||||||
|
return Number(wk[met]) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: trend.map((m: any) => ({
|
||||||
|
label: m.memberName,
|
||||||
|
data: labels.map((w) => valueFor(m, w, metric)),
|
||||||
|
borderColor: m.memberColor || '#6366f1',
|
||||||
|
backgroundColor: m.memberColor || '#6366f1',
|
||||||
|
tension: 0.35,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 3,
|
||||||
|
borderWidth: 2
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function totalChoresFor(memberId: string) {
|
function totalChoresFor(memberId: string) {
|
||||||
return assigned.filter((a: any) => a.memberId === memberId).length;
|
return assigned.filter((a: any) => a.memberId === memberId).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weekly completion potential for a member: each daily chore = 7 instances,
|
||||||
|
// each weekly/todo chore = 1. (The raw `s.totalChores` is a template count,
|
||||||
|
// which is why the old donut could read >100%.)
|
||||||
|
function weeklyPotentialFor(memberId: string) {
|
||||||
|
return assigned
|
||||||
|
.filter((a: any) => a.memberId === memberId)
|
||||||
|
.reduce((sum: number, a: any) => sum + (a.frequency === 'daily' ? 7 : 1), 0);
|
||||||
|
}
|
||||||
|
// Chores expected "today" = the member's daily chores (each due once today).
|
||||||
|
function todayPotentialFor(memberId: string) {
|
||||||
|
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
|
||||||
|
}
|
||||||
|
// Max points a full "all daily chores done" day is worth for a member.
|
||||||
|
function dailyPointsPotential(memberId: string) {
|
||||||
|
return assigned
|
||||||
|
.filter((a: any) => a.memberId === memberId && a.frequency === 'daily')
|
||||||
|
.reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0);
|
||||||
|
}
|
||||||
|
// Days elapsed in the current week (min 1), for a running daily average.
|
||||||
|
function daysElapsed(): number {
|
||||||
|
const days = summary?.daysInWeek || [];
|
||||||
|
const today = todayInTz(famTz);
|
||||||
|
return Math.max(1, days.filter((d: string) => d <= today).length);
|
||||||
|
}
|
||||||
|
// Average daily points from chore completions this week.
|
||||||
|
function avgDailyPointsFor(s: any): number {
|
||||||
|
const total = Object.values(s.dayPoints || {}).reduce(
|
||||||
|
(sum: number, v: any) => sum + (Number(v) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return total / daysElapsed();
|
||||||
|
}
|
||||||
|
// Today's completion count — tolerant of UTC vs fam-local date keys.
|
||||||
|
function todayDone(s: any): number {
|
||||||
|
if (!s.dayCompletions) return 0;
|
||||||
|
const utc = new Date().toISOString().slice(0, 10);
|
||||||
|
if (s.dayCompletions[utc] != null) return s.dayCompletions[utc];
|
||||||
|
const local = new Date().toLocaleDateString('en-CA');
|
||||||
|
return s.dayCompletions[local] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distinctTrendWeeks(): number {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const m of trend) for (const w of m.weeks || []) set.add(w.weekStart);
|
||||||
|
return set.size;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!summary?.summaries?.length) return;
|
if (!summary?.summaries?.length) return;
|
||||||
|
|
||||||
@@ -54,7 +164,7 @@
|
|||||||
label: s.memberName,
|
label: s.memberName,
|
||||||
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
|
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
|
||||||
backgroundColor: s.memberColor || '#6366f1',
|
backgroundColor: s.memberColor || '#6366f1',
|
||||||
borderRadius: 4,
|
borderRadius: 4
|
||||||
}));
|
}));
|
||||||
choresChart = new Chart(choresCanvas, {
|
choresChart = new Chart(choresCanvas, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -62,45 +172,122 @@
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: summary.summaries.length > 1, position: 'bottom' },
|
legend: { display: summary.summaries.length > 1, position: 'bottom' }
|
||||||
},
|
},
|
||||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDonut(canvas: HTMLCanvasElement, done: number, total: number, color: string) {
|
||||||
|
const remaining = Math.max(0, total - done);
|
||||||
|
return new Chart(canvas, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Done', 'Remaining'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [done, remaining],
|
||||||
|
backgroundColor: [color || '#22c55e', '#e5e7eb'],
|
||||||
|
borderWidth: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of summary.summaries) {
|
for (const s of summary.summaries) {
|
||||||
const canvas = donutCanvases[s.memberId];
|
const todayCanvas = donutCanvases[`${s.memberId}:today`];
|
||||||
if (!canvas) continue;
|
if (todayCanvas) {
|
||||||
const done = s.choresCompleted || 0;
|
donutCharts[`${s.memberId}:today`] = makeDonut(
|
||||||
const total = s.totalChores || 0;
|
todayCanvas,
|
||||||
const remaining = Math.max(0, total - done);
|
todayDone(s),
|
||||||
donutCharts[s.memberId] = new Chart(canvas, {
|
todayPotentialFor(s.memberId),
|
||||||
type: 'doughnut',
|
s.memberColor
|
||||||
data: {
|
);
|
||||||
labels: ['Done', 'Remaining'],
|
}
|
||||||
datasets: [{
|
const weekCanvas = donutCanvases[`${s.memberId}:week`];
|
||||||
data: [done, remaining],
|
if (weekCanvas) {
|
||||||
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
|
donutCharts[`${s.memberId}:week`] = makeDonut(
|
||||||
borderWidth: 0,
|
weekCanvas,
|
||||||
}],
|
s.choresCompleted || 0,
|
||||||
|
weeklyPotentialFor(s.memberId),
|
||||||
|
s.memberColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
|
||||||
|
if (scoreCanvas) {
|
||||||
|
donutCharts[`${s.memberId}:score`] = makeDonut(
|
||||||
|
scoreCanvas,
|
||||||
|
avgDailyPointsFor(s),
|
||||||
|
dailyPointsPotential(s.memberId),
|
||||||
|
s.memberColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render the trend line chart when the metric view changes.
|
||||||
|
$effect(() => {
|
||||||
|
metric;
|
||||||
|
trend;
|
||||||
|
if (!trendCanvas) return;
|
||||||
|
const { labels, datasets } = computeTrend();
|
||||||
|
if (trendChart) {
|
||||||
|
trendChart.data.labels = labels;
|
||||||
|
trendChart.data.datasets = datasets;
|
||||||
|
trendChart.update();
|
||||||
|
} else if (datasets.length > 0) {
|
||||||
|
trendChart = new Chart(trendCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: trend.length > 1, position: 'bottom' },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: any) => {
|
||||||
|
const v = ctx.parsed.y;
|
||||||
|
if (metric === 'cash') return ` ${ctx.dataset.label}: £${Number(v).toFixed(2)}`;
|
||||||
|
if (metric === 'chores') return ` ${ctx.dataset.label}: ${v} chores`;
|
||||||
|
return ` ${ctx.dataset.label}: ${v} pts`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } },
|
scales: { y: { beginAtZero: true } }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
choresChart?.destroy();
|
choresChart?.destroy();
|
||||||
|
trendChart?.destroy();
|
||||||
for (const c of Object.values(donutCharts)) c?.destroy();
|
for (const c of Object.values(donutCharts)) c?.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
title={famSlug}
|
title={page.data.fam?.name || famSlug}
|
||||||
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
|
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
|
||||||
hero
|
hero
|
||||||
/>
|
>
|
||||||
|
<!-- All-time family totals, large in the header -->
|
||||||
|
<div class="vh-stats grow text-right">
|
||||||
|
<span class="vh-stat">
|
||||||
|
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
|
||||||
|
<span class="vh-lbl">points all time</span>
|
||||||
|
</span>
|
||||||
|
<span class="vh-stat">
|
||||||
|
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
|
||||||
|
<span class="vh-lbl">cash earned all time</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ViewHeader>
|
||||||
|
|
||||||
<!-- STAT TILES -->
|
<!-- STAT TILES -->
|
||||||
<div class="tiles">
|
<div class="tiles">
|
||||||
@@ -116,25 +303,44 @@
|
|||||||
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
|
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
|
||||||
<span class="tile-lbl">cash this week</span>
|
<span class="tile-lbl">cash this week</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile tile-chores">
|
|
||||||
<span class="tile-val">{tileChores}</span>
|
|
||||||
<span class="tile-lbl">chores done</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
|
<!-- CHORES: this week | total -->
|
||||||
|
<Card cols={1} title="Chores">
|
||||||
|
<div class="chores-totals">
|
||||||
|
<div class="chore-total">
|
||||||
|
<span class="ct-val">{tileChores}</span>
|
||||||
|
<span class="ct-lbl">this week</span>
|
||||||
|
</div>
|
||||||
|
<div class="chore-total">
|
||||||
|
<span class="ct-val">{allTimeChores}</span>
|
||||||
|
<span class="ct-lbl">total all time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas bind:this={choresCanvas} height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- MEMBERS KANBAN -->
|
<!-- MEMBERS KANBAN -->
|
||||||
<Card cols={3} scrollX title="Members">
|
<Card cols={2} scrollX title="Members">
|
||||||
{#if !summary?.summaries?.length}
|
{#if !summary?.summaries?.length}
|
||||||
<p class="empty">No data yet this week</p>
|
<p class="empty">No data yet this week</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
{#each summary.summaries as s}
|
{#each summary.summaries as s}
|
||||||
{@const m = members.find((m2: any) => m2.id === s.memberId)}
|
{@const m = members.find((m2: any) => m2.id === s.memberId)}
|
||||||
{@const total = totalChoresFor(s.memberId)}
|
{@const wkTotal = weeklyPotentialFor(s.memberId)}
|
||||||
{@const pct = total > 0 ? Math.round((s.choresCompleted / total) * 100) : 0}
|
{@const wkDone = s.choresCompleted || 0}
|
||||||
|
{@const wkPct = wkTotal > 0 ? Math.min(100, Math.round((wkDone / wkTotal) * 100)) : 0}
|
||||||
|
{@const tdTotal = todayPotentialFor(s.memberId)}
|
||||||
|
{@const tdDone = todayDone(s)}
|
||||||
|
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
|
||||||
|
{@const scPot = dailyPointsPotential(s.memberId)}
|
||||||
|
{@const scVal = avgDailyPointsFor(s)}
|
||||||
|
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
||||||
<div class="column col-member">
|
<div class="column col-member">
|
||||||
<div class="member-hero">
|
|
||||||
<div class="member-head">
|
<div class="member-head">
|
||||||
<span class="dot" style="background:{s.memberColor}"></span>
|
<span class="dot" style="background:{s.memberColor}"></span>
|
||||||
<span class="member-name">{s.memberName}</span>
|
<span class="member-name">{s.memberName}</span>
|
||||||
@@ -142,9 +348,21 @@
|
|||||||
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="donuts">
|
||||||
<div class="donut-wrap">
|
<div class="donut-wrap">
|
||||||
<canvas use:captureCanvas={s.memberId} height="100" width="100"></canvas>
|
<canvas use:captureCanvas={`${s.memberId}:today`} height="100" width="100"></canvas>
|
||||||
<div class="donut-center" style="color:{s.memberColor}">{pct}%</div>
|
<div class="donut-center" style="color:{s.memberColor}">{tdPct}%</div>
|
||||||
|
<span class="donut-label">Today</span>
|
||||||
|
</div>
|
||||||
|
<div class="donut-wrap">
|
||||||
|
<canvas use:captureCanvas={`${s.memberId}:week`} height="100" width="100"></canvas>
|
||||||
|
<div class="donut-center" style="color:{s.memberColor}">{wkPct}%</div>
|
||||||
|
<span class="donut-label">Week</span>
|
||||||
|
</div>
|
||||||
|
<div class="donut-wrap">
|
||||||
|
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
|
||||||
|
<div class="donut-center" style="color:{s.memberColor}">{scPct}%</div>
|
||||||
|
<span class="donut-label">Avg/day</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-stats">
|
<div class="member-stats">
|
||||||
@@ -158,12 +376,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-lbl">Done</span>
|
<span class="stat-lbl">Done</span>
|
||||||
<span class="stat-val">{s.choresCompleted}/{total}</span>
|
<span class="stat-val">{wkDone}/{wkTotal}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-row">
|
|
||||||
<div class="bar-wrap">
|
|
||||||
<div class="bar-fill" style="width:{pct}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,44 +385,126 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- CHORES PER DAY CHART -->
|
<!-- WEEKLY TREND (cash / points / chores per member) -->
|
||||||
<Card cols={2} title="Chores per day">
|
<Card cols={3} title="Over time">
|
||||||
{#if summary?.summaries?.length}
|
<div class="trend-tabs">
|
||||||
|
<button
|
||||||
|
class="trend-tab"
|
||||||
|
class:active={metric === 'points'}
|
||||||
|
onclick={() => (metric = 'points')}>Points</button
|
||||||
|
>
|
||||||
|
<button class="trend-tab" class:active={metric === 'cash'} onclick={() => (metric = 'cash')}
|
||||||
|
>Cash</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="trend-tab"
|
||||||
|
class:active={metric === 'chores'}
|
||||||
|
onclick={() => (metric = 'chores')}>Chores</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if distinctTrendWeeks() === 0}
|
||||||
|
<p class="empty">No weekly data yet</p>
|
||||||
|
{:else}
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
<canvas bind:this={choresCanvas} height="200"></canvas>
|
<canvas bind:this={trendCanvas} height="220"></canvas>
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="empty">No data yet</p>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- TEMPLATES -->
|
|
||||||
<Card cols={1} title="Templates ({templates.length})">
|
|
||||||
{#if templates.length === 0}
|
|
||||||
<p class="empty">No templates created</p>
|
|
||||||
{:else}
|
|
||||||
<div class="template-list">
|
|
||||||
{#each templates as t}
|
|
||||||
<div class="template-chip">
|
|
||||||
<span class="tpl-name">{t.name}</span>
|
|
||||||
<span class="tpl-meta">{t.defaultFrequency} · {t.defaultValue} {t.defaultType}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* ── All-time header stats ── */
|
||||||
|
.vh-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.vh-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.vh-num {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.vh-num.pts {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-lbl {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chores card totals ── */
|
||||||
|
.chores-totals {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.chore-total {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ct-val {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ct-lbl {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.trend-tab {
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trend-tab.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tiles (matching child dashboard) ── */
|
/* ── Tiles (matching child dashboard) ── */
|
||||||
.tiles {
|
.tiles {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.tiles { grid-template-columns: repeat(2, 1fr); }
|
.tiles {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -220,11 +515,22 @@
|
|||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
.tile-members { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
.tile-members {
|
||||||
.tile-pts { background: linear-gradient(135deg, #7c3aed, #a855f7); }
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
.tile-cash { background: linear-gradient(135deg, #059669, #10b981); }
|
}
|
||||||
.tile-chores { background: linear-gradient(135deg, #0284c7, #38bdf8); }
|
.tile-pts {
|
||||||
.tile-val { font-size: 1.6rem; font-weight: 800; }
|
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||||
|
}
|
||||||
|
.tile-cash {
|
||||||
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
|
}
|
||||||
|
.tile-chores {
|
||||||
|
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||||
|
}
|
||||||
|
.tile-val {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
.tile-lbl {
|
.tile-lbl {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -246,14 +552,11 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #fafbff;
|
background: #fafbff;
|
||||||
}
|
}
|
||||||
.col-member { border-top-color: #6366f1; background: #eef2ff; }
|
.col-member {
|
||||||
|
border-top-color: #6366f1;
|
||||||
.member-hero {
|
background: #eef2ff;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-head {
|
.member-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -263,7 +566,8 @@
|
|||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 10px; height: 10px;
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -273,6 +577,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.kanban-link {
|
.kanban-link {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -281,15 +587,26 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.kanban-link:hover { text-decoration: underline; }
|
.kanban-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donuts {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin: 3rem 0;
|
||||||
|
}
|
||||||
.donut-wrap {
|
.donut-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 70px; height: 70px;
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.donut-wrap canvas {
|
.donut-wrap canvas {
|
||||||
max-width: 70px; max-height: 70px;
|
max-width: 62px;
|
||||||
|
max-height: 62px;
|
||||||
}
|
}
|
||||||
.donut-center {
|
.donut-center {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -297,10 +614,22 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.donut-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -15px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
.member-stats {
|
.member-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -314,50 +643,27 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.stat-lbl { color: #6b7280; font-weight: 500; }
|
.stat-lbl {
|
||||||
.stat-val { font-weight: 700; }
|
color: #6b7280;
|
||||||
.stat-val.pts { color: #7c3aed; }
|
font-weight: 500;
|
||||||
.stat-val.cash { color: #059669; }
|
|
||||||
|
|
||||||
.progress-row {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
}
|
||||||
.bar-wrap {
|
.stat-val {
|
||||||
height: 8px;
|
font-weight: 700;
|
||||||
background: #e5e7eb;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.bar-fill {
|
.stat-val.pts {
|
||||||
height: 100%;
|
color: #7c3aed;
|
||||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
}
|
||||||
border-radius: 4px;
|
.stat-val.cash {
|
||||||
transition: width 0.3s ease;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chart ── */
|
/* ── Chart ── */
|
||||||
.chart-wrap { min-height: 200px; }
|
.chart-wrap {
|
||||||
.chart-wrap canvas { max-height: 300px; }
|
min-height: 200px;
|
||||||
|
|
||||||
/* ── Templates ── */
|
|
||||||
.template-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
}
|
||||||
.template-chip {
|
.chart-wrap canvas {
|
||||||
display: flex;
|
max-height: 300px;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-left: 4px solid #6366f1;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafbff;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
.tpl-name { font-weight: 600; font-size: 0.85rem; }
|
|
||||||
.tpl-meta { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* ── Shared ── */
|
/* ── Shared ── */
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function load(event) {
|
|||||||
s.crud.list('rewards', famId),
|
s.crud.list('rewards', famId),
|
||||||
s.crud.list('bonus-configs', famId),
|
s.crud.list('bonus-configs', famId),
|
||||||
s.crud.list('completions', famId),
|
s.crud.list('completions', famId),
|
||||||
s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' }))
|
s.settings.get(famId)
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
role: 'parent',
|
role: 'parent',
|
||||||
@@ -105,8 +105,7 @@ export async function load(event) {
|
|||||||
tallies: {} as Record<string, unknown>,
|
tallies: {} as Record<string, unknown>,
|
||||||
payday: chores.payday,
|
payday: chores.payday,
|
||||||
paydayTime: chores.paydayTime || '18:00',
|
paydayTime: chores.paydayTime || '18:00',
|
||||||
timezone: chores.timezone || 'auto',
|
timezone: chores.timezone || 'auto'
|
||||||
simulateEow: !!chores.simulateEow
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return empty;
|
return empty;
|
||||||
@@ -114,33 +113,6 @@ export async function load(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
setEow: async (event) => {
|
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
|
||||||
const famId = event.locals.user.famId;
|
|
||||||
const fd = await event.request.formData();
|
|
||||||
const on = fd.get('on') === 'true';
|
|
||||||
try {
|
|
||||||
const s = createServices(pbUser(event), event.locals.user);
|
|
||||||
const result = await s.settings.update(famId, { simulateEow: on });
|
|
||||||
return { simulateEow: result.simulateEow };
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
previewEow: async (event) => {
|
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
|
||||||
const famId = event.locals.user.famId;
|
|
||||||
try {
|
|
||||||
const s = createServices(pbUser(event), event.locals.user);
|
|
||||||
const preview = await s.fam.eowPreview(famId);
|
|
||||||
await s.settings.update(famId, { simulateEow: true });
|
|
||||||
return { preview, simulateEow: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
claim: async (event) => {
|
claim: async (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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
|||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||||
|
import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons';
|
||||||
import { formatHumanDate } from '$lib/format';
|
import { formatHumanDate } from '$lib/format';
|
||||||
|
import { nextPaydayAfter, todayInTz, resolveTz } from '@shared/timezone';
|
||||||
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
|
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
|
||||||
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
||||||
|
|
||||||
@@ -47,8 +49,26 @@
|
|||||||
|
|
||||||
// Curated celebration emojis for todos
|
// Curated celebration emojis for todos
|
||||||
const EMOJI_CHOICES = [
|
const EMOJI_CHOICES = [
|
||||||
'🎉', '⭐', '🏆', '💪', '🧹', '📚', '🦸', '✨', '🌟', '🥇',
|
'🎉',
|
||||||
'🎯', '💚', '🚀', '🦄', '🌈', '🍀', '🎨', '🎸', '⚽', '🍰'
|
'⭐',
|
||||||
|
'🏆',
|
||||||
|
'💪',
|
||||||
|
'🧹',
|
||||||
|
'📚',
|
||||||
|
'🦸',
|
||||||
|
'✨',
|
||||||
|
'🌟',
|
||||||
|
'🥇',
|
||||||
|
'🎯',
|
||||||
|
'💚',
|
||||||
|
'🚀',
|
||||||
|
'🦄',
|
||||||
|
'🌈',
|
||||||
|
'🍀',
|
||||||
|
'🎨',
|
||||||
|
'🎸',
|
||||||
|
'⚽',
|
||||||
|
'🍰'
|
||||||
];
|
];
|
||||||
|
|
||||||
let editFreq = $state('daily');
|
let editFreq = $state('daily');
|
||||||
@@ -67,9 +87,11 @@
|
|||||||
|
|
||||||
// Platform-owned templates only — families assign these, they don't create.
|
// Platform-owned templates only — families assign these, they don't create.
|
||||||
let templates = $derived(
|
let templates = $derived(
|
||||||
((famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])) || []).filter(
|
(
|
||||||
(t: ChoreTemplate) => t.global
|
(famStore.initialized
|
||||||
)
|
? (famStore.templates as ChoreTemplate[])
|
||||||
|
: (data.templates as ChoreTemplate[])) || []
|
||||||
|
).filter((t: ChoreTemplate) => t.global)
|
||||||
);
|
);
|
||||||
let members = $state(
|
let members = $state(
|
||||||
famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || []
|
famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || []
|
||||||
@@ -84,7 +106,7 @@
|
|||||||
// Only active seasons are selectable for assignment / filtering.
|
// Only active seasons are selectable for assignment / filtering.
|
||||||
let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false));
|
let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false));
|
||||||
let completions = $state((data.completions as Completion[]) || []);
|
let completions = $state((data.completions as Completion[]) || []);
|
||||||
|
// $effect(() => console.log(seasons, activeSeasons));
|
||||||
let filteredAssigned = $derived(
|
let filteredAssigned = $derived(
|
||||||
seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter))
|
seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter))
|
||||||
);
|
);
|
||||||
@@ -207,13 +229,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort todos by completeBy (soonest first), then by startDate
|
// Sort todos by completeBy (soonest first), then by startDate
|
||||||
function sortedTodosForMember(memberId: string): AssignedChore[] {
|
function todoSort(a: AssignedChore, b: AssignedChore): number {
|
||||||
return todosForMember(memberId).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';
|
||||||
if (da !== db) return da.localeCompare(db);
|
if (da !== db) return da.localeCompare(db);
|
||||||
return (a.startDate || '').localeCompare(b.startDate || '');
|
return (a.startDate || '').localeCompare(b.startDate || '');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// Family payday anchor + resolved tz for week-boundary maths.
|
||||||
|
const famPayday = $derived(
|
||||||
|
(famStore.fam as any as { payday?: number })?.payday != null
|
||||||
|
? Number((famStore.fam as any).payday)
|
||||||
|
: 1
|
||||||
|
);
|
||||||
|
const famTz = $derived(resolveTz((famStore.fam as any)?.timezone || 'auto'));
|
||||||
|
|
||||||
|
// A completed todo stays visible (green "TBC completed") for the rest of the
|
||||||
|
// week it was completed; once the next payday rolls around (a new week
|
||||||
|
// starts) it drops off the admin Todos list.
|
||||||
|
function comprisesTodo(todo: AssignedChore): boolean {
|
||||||
|
if (!isTodoCompleted(todo.id)) return false;
|
||||||
|
const completion = completions.find((c: Completion) => c.assignedChoreId === todo.id);
|
||||||
|
const done = (completion?.date || '').slice(0, 10);
|
||||||
|
if (!done) return true;
|
||||||
|
const nextPayday = nextPaydayAfter(done, famPayday, famTz);
|
||||||
|
return todayInTz(famTz) >= nextPayday;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All todos shown for a member — but once the week they were completed in is
|
||||||
|
// over (payday has passed) the completed todo is hidden from the list.
|
||||||
|
function visibleTodosForMember(memberId: string): AssignedChore[] {
|
||||||
|
return todosForMember(memberId)
|
||||||
|
.filter((a) => !comprisesTodo(a))
|
||||||
|
.sort(todoSort);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7 days from today as YYYY-MM-DD for the todo modal default
|
// 7 days from today as YYYY-MM-DD for the todo modal default
|
||||||
@@ -305,21 +353,12 @@
|
|||||||
<p class="error">{form.error}</p>
|
<p class="error">{form.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid stackTablet>
|
||||||
<!-- Templates Section (at top, like bonuses) -->
|
<!-- Templates Section (at top, like bonuses) -->
|
||||||
<Card cols={3}>
|
<Card cols={3}>
|
||||||
<div class="template-section">
|
<div class="template-section">
|
||||||
<div class="template-header">
|
<div class="template-header">
|
||||||
<h3>Templates — Drag to assign</h3>
|
<h3>Templates — Drag to assign</h3>
|
||||||
<div class="season-filter">
|
|
||||||
<label>Season:</label>
|
|
||||||
<select bind:value={seasonFilter}>
|
|
||||||
<option value="all">All Chores</option>
|
|
||||||
{#each activeSeasons as s}
|
|
||||||
<option value={s.id}>{s.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="template-list">
|
<div class="template-list">
|
||||||
{#each templates as t}
|
{#each templates as t}
|
||||||
@@ -349,11 +388,22 @@
|
|||||||
|
|
||||||
<!-- Member Chore Assignment Kanban (3-column grid) -->
|
<!-- Member Chore Assignment Kanban (3-column grid) -->
|
||||||
<Card cols={3} scrollX>
|
<Card cols={3} scrollX>
|
||||||
|
<div class="kanban-toolbar">
|
||||||
|
<div class="season-filter">
|
||||||
|
<label>Season:</label>
|
||||||
|
<select bind:value={seasonFilter}>
|
||||||
|
<option value="all">All Chores</option>
|
||||||
|
{#each activeSeasons as s}
|
||||||
|
<option value={s.id}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
<div
|
<div
|
||||||
class="column member-col"
|
class="column member-col"
|
||||||
style="border-top-color: {m.color}"
|
style="border-top-color: {m.color}; --mc: {m.color}"
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
ondrop={(e) => handleDrop(e, m.id)}
|
ondrop={(e) => handleDrop(e, m.id)}
|
||||||
>
|
>
|
||||||
@@ -363,16 +413,25 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- TODOS accordion wrapper -->
|
<!-- TODOS accordion wrapper -->
|
||||||
<div class="accordion-wrapper accordion-todos">
|
<div
|
||||||
|
class="accordion-wrapper accordion-todos"
|
||||||
|
class:open={accordionState[m.id]?.todos ?? true}
|
||||||
|
>
|
||||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
||||||
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
<span class="accordion-title">
|
||||||
|
<span class="accordion-icon">{@html todoIcon}</span>
|
||||||
|
<span class="accordion-label">Todos</span>
|
||||||
|
</span>
|
||||||
<span class="accordion-chevron"
|
<span class="accordion-chevron"
|
||||||
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="accordion-count" style="background:{m.color}"
|
||||||
|
>{visibleTodosForMember(m.id).length}</span
|
||||||
|
>
|
||||||
{#if accordionState[m.id]?.todos ?? true}
|
{#if accordionState[m.id]?.todos ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
{#each sortedTodosForMember(m.id) as a}
|
{#each visibleTodosForMember(m.id) as a}
|
||||||
{@const completed = isTodoCompleted(a.id)}
|
{@const completed = isTodoCompleted(a.id)}
|
||||||
{@const urgency = todoUrgency(a)}
|
{@const urgency = todoUrgency(a)}
|
||||||
<div
|
<div
|
||||||
@@ -407,7 +466,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if completed}
|
{#if completed}
|
||||||
<span class="todo-completed-badge">✅ TBC completed</span>
|
<span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="del-btn"
|
class="del-btn"
|
||||||
@@ -433,13 +493,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CHORES accordion wrapper -->
|
<!-- CHORES accordion wrapper -->
|
||||||
<div class="accordion-wrapper accordion-chores">
|
<div
|
||||||
|
class="accordion-wrapper accordion-chores"
|
||||||
|
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-label">Chores ({assignedForMember(m.id).length})</span>
|
<span class="accordion-title">
|
||||||
|
<span class="accordion-icon">{@html choresIcon}</span>
|
||||||
|
<span class="accordion-label">Chores</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
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="accordion-count" style="background:{m.color}"
|
||||||
|
>{assignedForMember(m.id).length}</span
|
||||||
|
>
|
||||||
{#if accordionState[m.id]?.chores ?? true}
|
{#if accordionState[m.id]?.chores ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
{#each assignedForMember(m.id) as a}
|
{#each assignedForMember(m.id) as a}
|
||||||
@@ -449,17 +518,20 @@
|
|||||||
onclick={() => openEdit(a)}
|
onclick={() => openEdit(a)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(a.color)}"
|
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(
|
||||||
|
a.color
|
||||||
|
)}"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
||||||
<strong>{a.customName || tName}</strong>
|
<strong>{a.customName || tName}</strong>
|
||||||
<span class="badge">{a.frequency}</span>
|
<span class="badge">{a.frequency}</span>
|
||||||
<span class="badge type">{a.type}</span>
|
|
||||||
{#each a.seasonIds || [] as sid}
|
{#each a.seasonIds || [] as sid}
|
||||||
<span class="badge season" style="border-color:{seasonColor(sid)}; color:{seasonColor(sid)}"
|
<span
|
||||||
>{seasonName(sid)}</span
|
class="season-dot"
|
||||||
>
|
title={seasonName(sid)}
|
||||||
|
style="background:{seasonColor(sid)}"
|
||||||
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if (a.seasonIds || []).length === 0}
|
{#if (a.seasonIds || []).length === 0}
|
||||||
<span class="badge season muted">All year</span>
|
<span class="badge season muted">All year</span>
|
||||||
@@ -501,14 +573,16 @@
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<!-- Season pill selector (reused by assign + edit modals) -->
|
<!-- Season pill selector (reused by assign + edit modals) -->
|
||||||
{#snippet seasonPills(list: Season[], selected: string[], set: (v: string[]) => void)}
|
{#snippet seasonPills(list: Season[] = [], selected: string[] = [], set: (v: string[]) => void)}
|
||||||
<div class="season-pills">
|
<div class="season-pills" role="radiogroup" aria-label="Seasons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="season-pill"
|
class="season-pill"
|
||||||
class:selected={selected.length === 0}
|
class:selected={selected.length === 0}
|
||||||
style="--pill:#9ca3af"
|
style="--pill:#9ca3af"
|
||||||
onclick={() => set([])}>All year</button
|
role="radio"
|
||||||
|
aria-checked={selected.length === 0}
|
||||||
|
onclick={() => set([])}>Any</button
|
||||||
>
|
>
|
||||||
{#each list as s}
|
{#each list as s}
|
||||||
<button
|
<button
|
||||||
@@ -516,6 +590,8 @@
|
|||||||
class="season-pill"
|
class="season-pill"
|
||||||
class:selected={selected[0] === s.id}
|
class:selected={selected[0] === s.id}
|
||||||
style="--pill:{s.color}"
|
style="--pill:{s.color}"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected[0] === s.id}
|
||||||
onclick={() => set([s.id])}>{s.name}</button
|
onclick={() => set([s.id])}>{s.name}</button
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -524,16 +600,25 @@
|
|||||||
|
|
||||||
<!-- Assign from Template Modal -->
|
<!-- Assign from Template Modal -->
|
||||||
{#if assignModal}
|
{#if assignModal}
|
||||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) assignModal = null; }} role="presentation">
|
<div
|
||||||
|
class="overlay"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) assignModal = null;
|
||||||
|
}}
|
||||||
|
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">
|
<p class="hint">
|
||||||
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)?.name}'s
|
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)
|
||||||
chores. Amend, then Assign.
|
?.name}'s chores. Amend, then Assign.
|
||||||
</p>
|
</p>
|
||||||
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
||||||
<label
|
<label
|
||||||
>Description <input bind:value={assignDescription} placeholder="Optional description" /></label
|
>Description <input
|
||||||
|
bind:value={assignDescription}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/></label
|
||||||
>
|
>
|
||||||
<div class="color-field">
|
<div class="color-field">
|
||||||
<span class="field-label">Colour</span>
|
<span class="field-label">Colour</span>
|
||||||
@@ -548,7 +633,8 @@
|
|||||||
? '#e5e7eb'
|
? '#e5e7eb'
|
||||||
: c.value}"
|
: c.value}"
|
||||||
title={c.label}
|
title={c.label}
|
||||||
onclick={() => (assignColor = c.value)}></button>
|
onclick={() => (assignColor = c.value)}
|
||||||
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +667,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="season-field">
|
<div class="season-field">
|
||||||
<span class="field-label">Seasons</span>
|
<span class="field-label">Seasons</span>
|
||||||
<seasonPills activeSeasons selected={assignSeasonIds} set={(v: string[]) => (assignSeasonIds = v)} />
|
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
|
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
|
||||||
@@ -593,7 +679,13 @@
|
|||||||
|
|
||||||
<!-- Edit Assignment Modal -->
|
<!-- Edit Assignment Modal -->
|
||||||
{#if showEditModal && editingAssignment}
|
{#if showEditModal && editingAssignment}
|
||||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) closeEdit(); }} role="presentation">
|
<div
|
||||||
|
class="overlay"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeEdit();
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
<h3>
|
<h3>
|
||||||
{#if editingAssignment.isTodo}
|
{#if editingAssignment.isTodo}
|
||||||
@@ -611,7 +703,11 @@
|
|||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
>Description
|
>Description
|
||||||
<input name="description" bind:value={editDescription} placeholder="Optional description" /></label
|
<input
|
||||||
|
name="description"
|
||||||
|
bind:value={editDescription}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/></label
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if editingAssignment.isTodo}
|
{#if editingAssignment.isTodo}
|
||||||
@@ -631,7 +727,14 @@
|
|||||||
{:else if editType === 'money'}
|
{:else if editType === 'money'}
|
||||||
<label>
|
<label>
|
||||||
Cash amount (£)
|
Cash amount (£)
|
||||||
<input name="value" type="number" step="0.01" min="0.01" bind:value={editValue} required />
|
<input
|
||||||
|
name="value"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
bind:value={editValue}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<input type="hidden" name="emoji" value={editEmoji} />
|
<input type="hidden" name="emoji" value={editEmoji} />
|
||||||
@@ -640,7 +743,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="link-btn"
|
class="link-btn"
|
||||||
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}>
|
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
|
||||||
|
>
|
||||||
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
|
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -683,12 +787,13 @@
|
|||||||
? '#e5e7eb'
|
? '#e5e7eb'
|
||||||
: c.value}"
|
: c.value}"
|
||||||
title={c.label}
|
title={c.label}
|
||||||
onclick={() => (editColor = c.value)}></button>
|
onclick={() => (editColor = c.value)}
|
||||||
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" value={editColor} />
|
<input type="hidden" name="color" value={editColor} />
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label class="icon-row"
|
||||||
><span class="icon-preview"
|
><span class="icon-preview"
|
||||||
><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span
|
><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span
|
||||||
>
|
>
|
||||||
@@ -717,7 +822,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="season-field">
|
<div class="season-field">
|
||||||
<span class="field-label">Seasons</span>
|
<span class="field-label">Seasons</span>
|
||||||
<seasonPills activeSeasons selected={editSeasonIds} set={(v: string[]) => (editSeasonIds = v)} />
|
{@render seasonPills(seasons, editSeasonIds, (v: string[]) => (editSeasonIds = v))}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -731,7 +836,13 @@
|
|||||||
|
|
||||||
<!-- Add Todo Modal -->
|
<!-- Add Todo Modal -->
|
||||||
{#if showTodoModal && todoMemberId}
|
{#if showTodoModal && todoMemberId}
|
||||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) showTodoModal = false; }} role="presentation">
|
<div
|
||||||
|
class="overlay"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) showTodoModal = false;
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
<h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3>
|
<h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3>
|
||||||
<form
|
<form
|
||||||
@@ -777,13 +888,24 @@
|
|||||||
{:else if todoType === 'money'}
|
{:else if todoType === 'money'}
|
||||||
<label>
|
<label>
|
||||||
Cash amount (£)
|
Cash amount (£)
|
||||||
<input name="value" type="number" step="0.01" min="0.01" bind:value={todoValue} required />
|
<input
|
||||||
|
name="value"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
bind:value={todoValue}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<input type="hidden" name="emoji" value={todoEmoji} />
|
<input type="hidden" name="emoji" value={todoEmoji} />
|
||||||
<div class="emoji-field">
|
<div class="emoji-field">
|
||||||
<span class="emoji-preview">{todoEmoji}</span>
|
<span class="emoji-preview">{todoEmoji}</span>
|
||||||
<button type="button" class="link-btn" onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-btn"
|
||||||
|
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
|
||||||
|
>
|
||||||
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
|
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,6 +996,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.template-list {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.template-list .card.template {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
||||||
.kanban {
|
.kanban {
|
||||||
@@ -1038,6 +1168,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.kanban-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
.season-filter label {
|
.season-filter label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
@@ -1059,6 +1194,14 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
border-color: #e5e7eb;
|
border-color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
.season-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
.card-meta {
|
.card-meta {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
@@ -1234,37 +1377,45 @@
|
|||||||
|
|
||||||
/* ── Accordion wrappers ── */
|
/* ── Accordion wrappers ── */
|
||||||
.accordion-wrapper {
|
.accordion-wrapper {
|
||||||
background: #6366f1;
|
position: relative;
|
||||||
border-radius: 10px;
|
|
||||||
margin: 0.75rem 0 0.3rem;
|
margin: 0.75rem 0 0.3rem;
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25);
|
|
||||||
}
|
}
|
||||||
.accordion-wrapper.accordion-todos {
|
.accordion-wrapper:first-child {
|
||||||
background: #2e8b57;
|
margin-top: 1.1rem;
|
||||||
box-shadow: 0 2px 6px rgba(46, 139, 87, 0.25);
|
|
||||||
}
|
}
|
||||||
.accordion-head {
|
.accordion-head {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.55rem 0.85rem;
|
padding: 0.55rem 0.85rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 4px solid rgba(0, 0, 0, 0.2);
|
border-left: 4px solid rgba(0, 0, 0, 0.2);
|
||||||
background: transparent;
|
background: var(--mc, #6366f1);
|
||||||
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: filter 0.15s;
|
transition: filter 0.15s;
|
||||||
}
|
}
|
||||||
|
.accordion-wrapper.accordion-todos .accordion-head {
|
||||||
|
background: oklch(from var(--mc, #6366f1) calc(l + 0.08) c h);
|
||||||
|
}
|
||||||
|
.accordion-wrapper.open .accordion-head {
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
}
|
||||||
.accordion-head:hover {
|
.accordion-head:hover {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
.accordion-body {
|
.accordion-body {
|
||||||
padding: 0.5rem 0.65rem 0.65rem;
|
padding: 0.5rem 0.65rem 0.65rem;
|
||||||
background: rgba(0, 0, 0, 0.08);
|
background: oklch(from var(--mc, #6366f1) calc(l - 0.1) c h);
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
}
|
||||||
|
.accordion-wrapper.accordion-todos .accordion-body {
|
||||||
|
background: oklch(from var(--mc, #6366f1) calc(l - 0.02) c h);
|
||||||
}
|
}
|
||||||
.accordion-chevron {
|
.accordion-chevron {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1272,6 +1423,40 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
.accordion-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.accordion-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.accordion-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.accordion-count {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Todo admin cards ── */
|
/* ── Todo admin cards ── */
|
||||||
.todo-admin-card {
|
.todo-admin-card {
|
||||||
@@ -1393,6 +1578,13 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.todo-completed-badge :global(svg) {
|
||||||
|
width: 0.95em;
|
||||||
|
height: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Points badge ── */
|
/* ── Points badge ── */
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
<div class="toast">{toast}</div>
|
<div class="toast">{toast}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid stackTablet>
|
||||||
{#if activeTab === 'rewards'}
|
{#if activeTab === 'rewards'}
|
||||||
<Card cols={3}>
|
<Card cols={3}>
|
||||||
{#if rewards.length === 0}
|
{#if rewards.length === 0}
|
||||||
|
|||||||
+16
-1
@@ -119,6 +119,12 @@ export const actions = {
|
|||||||
if (description) data.description = description;
|
if (description) data.description = description;
|
||||||
const memberId = fd.get('memberId');
|
const memberId = fd.get('memberId');
|
||||||
if (memberId) data.memberId = memberId;
|
if (memberId) data.memberId = memberId;
|
||||||
|
const completeBy = fd.get('completeBy');
|
||||||
|
if (completeBy) data.completeBy = completeBy;
|
||||||
|
const startDate = fd.get('startDate');
|
||||||
|
if (startDate) data.startDate = startDate;
|
||||||
|
const completeByDate = fd.get('completeByDate');
|
||||||
|
if (completeByDate) data.completeByDate = completeByDate;
|
||||||
try {
|
try {
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const record = await s.crud.create('bonus-configs', famId, data);
|
const record = await s.crud.create('bonus-configs', famId, data);
|
||||||
@@ -157,6 +163,12 @@ export const actions = {
|
|||||||
if (description) data.description = description;
|
if (description) data.description = description;
|
||||||
const memberId = fd.get('memberId');
|
const memberId = fd.get('memberId');
|
||||||
if (memberId !== null) data.memberId = memberId || null;
|
if (memberId !== null) data.memberId = memberId || null;
|
||||||
|
const completeBy = fd.get('completeBy');
|
||||||
|
if (completeBy) data.completeBy = completeBy;
|
||||||
|
const startDate = fd.get('startDate');
|
||||||
|
if (startDate !== null) data.startDate = startDate || '';
|
||||||
|
const completeByDate = fd.get('completeByDate');
|
||||||
|
if (completeByDate !== null) data.completeByDate = completeByDate || '';
|
||||||
try {
|
try {
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const record = await s.crud.update('bonus-configs', famId, id, data);
|
const record = await s.crud.update('bonus-configs', famId, id, data);
|
||||||
@@ -199,7 +211,10 @@ export const actions = {
|
|||||||
period: fd.get('period') || '',
|
period: fd.get('period') || '',
|
||||||
memberId: fd.get('memberId') || '',
|
memberId: fd.get('memberId') || '',
|
||||||
status,
|
status,
|
||||||
isPocketMoney: !!fd.get('isPocketMoney')
|
isPocketMoney: !!fd.get('isPocketMoney'),
|
||||||
|
completeBy: fd.get('completeBy') || 'unlimited',
|
||||||
|
startDate: fd.get('startDate') || '',
|
||||||
|
completeByDate: fd.get('completeByDate') || ''
|
||||||
};
|
};
|
||||||
if (data.occurrence === 'once') data.period = '';
|
if (data.occurrence === 'once') data.period = '';
|
||||||
try {
|
try {
|
||||||
+350
-42
@@ -5,7 +5,23 @@
|
|||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||||
|
import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone';
|
||||||
|
import { chevronRight } from '$lib/components/icons';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
|
import UsersRound from '@lucide/svelte/icons/users-round';
|
||||||
|
import Target from '@lucide/svelte/icons/target';
|
||||||
|
import Settings2 from '@lucide/svelte/icons/settings-2';
|
||||||
|
import CalendarDays from '@lucide/svelte/icons/calendar-days';
|
||||||
|
import Coins from '@lucide/svelte/icons/coins';
|
||||||
|
import Gift from '@lucide/svelte/icons/gift';
|
||||||
|
import PoundSterling from '@lucide/svelte/icons/pound-sterling';
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import Save from '@lucide/svelte/icons/save';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Crown from '@lucide/svelte/icons/crown';
|
||||||
|
import Ban from '@lucide/svelte/icons/ban';
|
||||||
import type {
|
import type {
|
||||||
BonusConfig,
|
BonusConfig,
|
||||||
BonusTemplate,
|
BonusTemplate,
|
||||||
@@ -17,9 +33,31 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// Today in the family's timezone (used for the default start date of a
|
||||||
|
// standalone reward — progress must not count points from before it began).
|
||||||
|
function todayIso(): string {
|
||||||
|
const famTz = resolveTz((famStore.fam as any)?.timezone || 'auto');
|
||||||
|
return todayInTz(famTz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbered-circle marker used for the Who / How / What step tabs and field
|
||||||
|
// labels (SVG string, same style as lib/components/icons.ts).
|
||||||
|
function stepIcon(n: number): string {
|
||||||
|
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><text x="12" y="16" text-anchor="middle" font-size="12" font-family="inherit" fill="currentColor" stroke="none">${n}</text></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
let showEditModal = $state(false);
|
let showEditModal = $state(false);
|
||||||
let editingConfig = $state<BonusConfig | null>(null);
|
let editingConfig = $state<BonusConfig | null>(null);
|
||||||
let creatingFromTemplate = $state<BonusTemplate | null>(null);
|
let creatingFromTemplate = $state<BonusTemplate | null>(null);
|
||||||
|
// Multi-step rule editor: 1 = who, 2 = how, 3 = what.
|
||||||
|
let step = $state(1);
|
||||||
|
|
||||||
|
// Kanban columns can collapse (accordion) so templates can be dragged onto
|
||||||
|
// the desired area on tablet/mobile without cramming three columns.
|
||||||
|
let collapsed = $state({ member: false, comp: false, collab: false });
|
||||||
|
function toggleCol(key: 'member' | 'comp' | 'collab') {
|
||||||
|
collapsed[key] = !collapsed[key];
|
||||||
|
}
|
||||||
|
|
||||||
let createVals = $state({
|
let createVals = $state({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -48,7 +86,10 @@
|
|||||||
rewardValue: '100',
|
rewardValue: '100',
|
||||||
criteriaValue: 10,
|
criteriaValue: 10,
|
||||||
period: '' as string,
|
period: '' as string,
|
||||||
startMode: 'today' as string
|
startMode: 'today' as string,
|
||||||
|
completeBy: 'unlimited' as string,
|
||||||
|
startDate: '' as string,
|
||||||
|
completeByDate: '' as string
|
||||||
});
|
});
|
||||||
|
|
||||||
let configs = $derived(
|
let configs = $derived(
|
||||||
@@ -71,8 +112,9 @@
|
|||||||
|
|
||||||
let showPeriod = $derived(editVals.occurrence !== 'once');
|
let showPeriod = $derived(editVals.occurrence !== 'once');
|
||||||
let showCriteria = $derived(editVals.type !== 'manual');
|
let showCriteria = $derived(editVals.type !== 'manual');
|
||||||
let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent');
|
|
||||||
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent');
|
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent');
|
||||||
|
// Standalone (no period) rewards can scope progress by completeBy + dates.
|
||||||
|
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
|
||||||
|
|
||||||
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(
|
||||||
@@ -149,8 +191,12 @@
|
|||||||
rewardValue: tpl.rewardValue,
|
rewardValue: tpl.rewardValue,
|
||||||
criteriaValue: tpl.criteriaValue || 10,
|
criteriaValue: tpl.criteriaValue || 10,
|
||||||
period: tpl.occurrence === 'once' ? '' : tpl.period || '',
|
period: tpl.occurrence === 'once' ? '' : tpl.period || '',
|
||||||
startMode: 'today'
|
startMode: 'today',
|
||||||
|
completeBy: 'unlimited',
|
||||||
|
startDate: todayIso(),
|
||||||
|
completeByDate: ''
|
||||||
};
|
};
|
||||||
|
step = 1;
|
||||||
showEditModal = true;
|
showEditModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +243,12 @@
|
|||||||
rewardValue: cfg.rewardValue,
|
rewardValue: cfg.rewardValue,
|
||||||
criteriaValue: cfg.criteriaValue || 10,
|
criteriaValue: cfg.criteriaValue || 10,
|
||||||
period: cfg.occurrence === 'once' ? '' : cfg.period || '',
|
period: cfg.occurrence === 'once' ? '' : cfg.period || '',
|
||||||
startMode: cfg.status === 'disabled' ? 'disabled' : 'today'
|
startMode: cfg.status === 'disabled' ? 'disabled' : 'today',
|
||||||
|
completeBy: cfg.completeBy || 'unlimited',
|
||||||
|
startDate: cfg.startDate || todayIso(),
|
||||||
|
completeByDate: cfg.completeByDate || ''
|
||||||
};
|
};
|
||||||
|
step = 1;
|
||||||
showEditModal = true;
|
showEditModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +260,10 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Bonuses" hero />
|
<ViewHeader title="Rewards" hero />
|
||||||
|
|
||||||
{#if pocketNotice}
|
{#if pocketNotice}
|
||||||
<CardGrid>
|
<CardGrid stackTablet>
|
||||||
<Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}>
|
<Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}>
|
||||||
<div class="pocket-notice">
|
<div class="pocket-notice">
|
||||||
<strong>Pocket Money</strong>
|
<strong>Pocket Money</strong>
|
||||||
@@ -231,7 +281,7 @@
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid stackTablet>
|
||||||
<Card cols={3}>
|
<Card cols={3}>
|
||||||
<div class="template-section">
|
<div class="template-section">
|
||||||
<h3>Templates — Drag to assign</h3>
|
<h3>Templates — Drag to assign</h3>
|
||||||
@@ -267,8 +317,12 @@
|
|||||||
<Card cols={3} scrollX>
|
<Card cols={3} scrollX>
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
<!-- Member Column -->
|
<!-- Member Column -->
|
||||||
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}>
|
<div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}>
|
||||||
<h3>Member</h3>
|
<button class="col-header" type="button" onclick={() => toggleCol('member')}>
|
||||||
|
<span class="col-title">Member</span>
|
||||||
|
<span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span>
|
||||||
|
</button>
|
||||||
|
<div class="col-body">
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
|
||||||
<div
|
<div
|
||||||
class="col-card"
|
class="col-card"
|
||||||
@@ -334,10 +388,15 @@
|
|||||||
<p class="empty">No bonuses</p>
|
<p class="empty">No bonuses</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Competition Column -->
|
<!-- Competition Column -->
|
||||||
<div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}>
|
<div class="column comp-col" class:collapsed={collapsed.comp} ondragover={handleDragOver} ondrop={handleDropToCompetition}>
|
||||||
<h3>Competition</h3>
|
<button class="col-header" type="button" onclick={() => toggleCol('comp')}>
|
||||||
|
<span class="col-title">Competition</span>
|
||||||
|
<span class="chev" class:open={!collapsed.comp}>{@html chevronRight}</span>
|
||||||
|
</button>
|
||||||
|
<div class="col-body">
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
|
||||||
{@const prog = progressByConfigId.get(cfg.id)}
|
{@const prog = progressByConfigId.get(cfg.id)}
|
||||||
<div
|
<div
|
||||||
@@ -395,10 +454,15 @@
|
|||||||
<p class="empty">No bonuses</p>
|
<p class="empty">No bonuses</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Collaboration Column -->
|
<!-- Collaboration Column -->
|
||||||
<div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
|
<div class="column collab-col" class:collapsed={collapsed.collab} ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
|
||||||
<h3>Collaboration</h3>
|
<button class="col-header" type="button" onclick={() => toggleCol('collab')}>
|
||||||
|
<span class="col-title">Collaboration</span>
|
||||||
|
<span class="chev" class:open={!collapsed.collab}>{@html chevronRight}</span>
|
||||||
|
</button>
|
||||||
|
<div class="col-body">
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
|
||||||
{@const prog = progressByConfigId.get(cfg.id)}
|
{@const prog = progressByConfigId.get(cfg.id)}
|
||||||
<div
|
<div
|
||||||
@@ -459,6 +523,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit / Create-from-Template Modal -->
|
<!-- Edit / Create-from-Template Modal -->
|
||||||
{#if showEditModal && (editingConfig || creatingFromTemplate)}
|
{#if showEditModal && (editingConfig || creatingFromTemplate)}
|
||||||
@@ -473,9 +538,32 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
<h3>
|
<h3 class="modal-title">
|
||||||
{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}
|
{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<!-- Step tabs -->
|
||||||
|
<div class="step-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step-tab"
|
||||||
|
class:active={step === 1}
|
||||||
|
onclick={() => (step = 1)}
|
||||||
|
>{@html stepIcon(1)}<span>Who</span></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step-tab"
|
||||||
|
class:active={step === 2}
|
||||||
|
onclick={() => (step = 2)}
|
||||||
|
>{@html stepIcon(2)}<span>How</span></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step-tab"
|
||||||
|
class:active={step === 3}
|
||||||
|
onclick={() => (step = 3)}
|
||||||
|
>{@html stepIcon(3)}<span>What</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'}
|
action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'}
|
||||||
@@ -487,11 +575,20 @@
|
|||||||
{#if creatingFromTemplate}
|
{#if creatingFromTemplate}
|
||||||
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} />
|
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} />
|
||||||
{/if}
|
{/if}
|
||||||
<label>Name <input name="name" bind:value={editVals.name} required /></label>
|
|
||||||
<label>Description <input name="description" bind:value={editVals.description} /></label
|
<!-- STEP 1 — Who (basics) -->
|
||||||
>
|
{#if step === 1}
|
||||||
<label
|
<div class="step-body">
|
||||||
>Target
|
<label>
|
||||||
|
<span class="field-label">{@html stepIcon(1)} Name</span>
|
||||||
|
<input name="name" bind:value={editVals.name} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="field-label">{@html stepIcon(1)} Description</span>
|
||||||
|
<input name="description" bind:value={editVals.description} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="field-label"><UsersRound size={14} /> Target (who)</span>
|
||||||
<select name="target" bind:value={editVals.target}>
|
<select name="target" bind:value={editVals.target}>
|
||||||
<option value="individual">Individual</option>
|
<option value="individual">Individual</option>
|
||||||
<option value="competitive">Competition</option>
|
<option value="competitive">Competition</option>
|
||||||
@@ -499,8 +596,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
|
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
|
||||||
<label
|
<label>
|
||||||
>Member
|
<span class="field-label"><UsersRound size={14} /> Member</span>
|
||||||
<select name="memberId" bind:value={editVals.memberId}>
|
<select name="memberId" bind:value={editVals.memberId}>
|
||||||
<option value="">All (everyone)</option>
|
<option value="">All (everyone)</option>
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
@@ -509,8 +606,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<label
|
</div>
|
||||||
>Type
|
{/if}
|
||||||
|
|
||||||
|
<!-- STEP 2 — How (rules + progress) -->
|
||||||
|
{#if step === 2}
|
||||||
|
<div class="step-body">
|
||||||
|
<label>
|
||||||
|
<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</option>
|
||||||
@@ -518,8 +621,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if editVals.type === 'threshold'}
|
{#if editVals.type === 'threshold'}
|
||||||
<label
|
<label>
|
||||||
>Threshold by
|
<span class="field-label"><Target size={14} /> Threshold by</span>
|
||||||
<select name="thresholdType" bind:value={editVals.thresholdType}>
|
<select name="thresholdType" bind:value={editVals.thresholdType}>
|
||||||
<option value="points">Points</option>
|
<option value="points">Points</option>
|
||||||
<option value="percent">% of chores done</option>
|
<option value="percent">% of chores done</option>
|
||||||
@@ -528,8 +631,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showCriteria}
|
{#if showCriteria}
|
||||||
{#if editIsPercent}
|
{#if editIsPercent}
|
||||||
<label
|
<label>
|
||||||
>Chores completed (%)
|
<span class="field-label"><Target size={14} /> Chores completed (%)</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -540,8 +643,8 @@
|
|||||||
<span class="hint">{editVals.criteriaValue}% of chores this week</span>
|
<span class="hint">{editVals.criteriaValue}% of chores this week</span>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<label
|
<label>
|
||||||
>Target Value
|
<span class="field-label"><Target size={14} /> Target Value</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="criteriaValue"
|
name="criteriaValue"
|
||||||
@@ -558,34 +661,102 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<label
|
<label>
|
||||||
>Occurrence
|
<span class="field-label"><Settings2 size={14} /> Occurrence</span>
|
||||||
<select name="occurrence" bind:value={editVals.occurrence}>
|
<select name="occurrence" bind:value={editVals.occurrence}>
|
||||||
<option value="recurring">Recurring</option>
|
<option value="recurring">Recurring</option>
|
||||||
<option value="once">Once</option>
|
<option value="once">Once</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if showPeriod}
|
{#if showPeriod}
|
||||||
<label
|
<label>
|
||||||
>Period
|
<span class="field-label"><CalendarDays size={14} /> Period</span>
|
||||||
<select name="period" bind:value={editVals.period}>
|
<select name="period" bind:value={editVals.period}>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Daily</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Weekly</option>
|
||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span class="hint"
|
||||||
|
>Recurring bonuses reset and re-earn each period. Leave as "None" for a
|
||||||
|
one-off reward measured once.</span
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showProgressWindow}
|
||||||
|
<div class="start-mode">
|
||||||
<label
|
<label
|
||||||
>Reward Type
|
><span class="field-label"><CalendarDays size={14} /> Progress counts</span
|
||||||
|
></label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="completeBy"
|
||||||
|
value="unlimited"
|
||||||
|
bind:group={editVals.completeBy}
|
||||||
|
/>
|
||||||
|
<span>Unlimited (until reached)</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="completeBy"
|
||||||
|
value="custom"
|
||||||
|
bind:group={editVals.completeBy}
|
||||||
|
/>
|
||||||
|
<span>Until a date</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if editVals.completeBy === 'custom'}
|
||||||
|
<label>
|
||||||
|
<span class="field-label"><CalendarDays size={14} /> Complete by</span>
|
||||||
|
<input type="date" name="completeByDate" bind:value={editVals.completeByDate} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<label>
|
||||||
|
<span class="field-label"><CalendarDays size={14} /> Progress starts on</span>
|
||||||
|
<input type="date" name="startDate" bind:value={editVals.startDate} />
|
||||||
|
<span class="hint"
|
||||||
|
>Points before this date won't count toward the reward.</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- STEP 3 — What (reward) -->
|
||||||
|
{#if step === 3}
|
||||||
|
<div class="step-body">
|
||||||
|
<label>
|
||||||
|
<span class="field-label">
|
||||||
|
{#if editVals.rewardType === 'cash'}
|
||||||
|
<PoundSterling size={14} />
|
||||||
|
{:else if editVals.rewardType === 'points'}
|
||||||
|
<Coins size={14} />
|
||||||
|
{:else}
|
||||||
|
<Gift size={14} />
|
||||||
|
{/if}
|
||||||
|
Reward Type
|
||||||
|
</span>
|
||||||
<select name="rewardType" bind:value={editVals.rewardType}>
|
<select name="rewardType" bind:value={editVals.rewardType}>
|
||||||
<option value="points">Points</option>
|
<option value="points">Points</option>
|
||||||
<option value="cash">Cash</option>
|
<option value="cash">Cash</option>
|
||||||
<option value="prize">Prize</option>
|
<option value="prize">Prize</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label>
|
||||||
>Reward Value
|
<span class="field-label">
|
||||||
|
{#if editVals.rewardType === 'cash'}
|
||||||
|
<PoundSterling size={14} />
|
||||||
|
{:else if editVals.rewardType === 'points'}
|
||||||
|
<Coins size={14} />
|
||||||
|
{:else}
|
||||||
|
<Gift size={14} />
|
||||||
|
{/if}
|
||||||
|
Reward Value
|
||||||
|
</span>
|
||||||
<input name="rewardValue" bind:value={editVals.rewardValue} required />
|
<input name="rewardValue" bind:value={editVals.rewardValue} required />
|
||||||
<span class="hint"
|
<span class="hint"
|
||||||
>{editVals.rewardType === 'cash'
|
>{editVals.rewardType === 'cash'
|
||||||
@@ -597,7 +768,9 @@
|
|||||||
</label>
|
</label>
|
||||||
{#if creatingFromTemplate}
|
{#if creatingFromTemplate}
|
||||||
<div class="start-mode">
|
<div class="start-mode">
|
||||||
<label>When to start</label>
|
<label
|
||||||
|
><span class="field-label"><CalendarDays size={14} /> When to start</span
|
||||||
|
></label>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@@ -614,6 +787,7 @@
|
|||||||
name="startMode"
|
name="startMode"
|
||||||
value="today"
|
value="today"
|
||||||
bind:group={editVals.startMode}
|
bind:group={editVals.startMode}
|
||||||
|
onchange={() => (editVals.startDate = todayIso())}
|
||||||
/>
|
/>
|
||||||
<span>Today</span>
|
<span>Today</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -623,13 +797,29 @@
|
|||||||
name="startMode"
|
name="startMode"
|
||||||
value="nextWeek"
|
value="nextWeek"
|
||||||
bind:group={editVals.startMode}
|
bind:group={editVals.startMode}
|
||||||
|
onchange={() => (editVals.startDate = addDaysStr(todayIso(), 7))}
|
||||||
/>
|
/>
|
||||||
<span>Next week</span>
|
<span>Next week</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer actions -->
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
{#if step === 2}
|
||||||
|
<button type="button" class="nav-btn" onclick={() => (step = 1)}>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
|
<button type="button" class="next-btn" onclick={() => (step = 3)}>
|
||||||
|
Next <ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
{:else if step === 3}
|
||||||
|
<button type="button" class="nav-btn" onclick={() => (step = 2)}>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
{#if editingConfig}
|
{#if editingConfig}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -643,6 +833,7 @@
|
|||||||
if (res.ok) showEditModal = false;
|
if (res.ok) showEditModal = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Ban size={14} />
|
||||||
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
|
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
|
||||||
</button>
|
</button>
|
||||||
{#if editingConfig.status !== 'completed'}
|
{#if editingConfig.status !== 'completed'}
|
||||||
@@ -658,7 +849,7 @@
|
|||||||
if (res.ok) showEditModal = false;
|
if (res.ok) showEditModal = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Mark Complete
|
<Crown size={14} /> Complete
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
@@ -676,10 +867,17 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
<Trash2 size={14} /> Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" class="save-btn">
|
||||||
|
<Save size={14} /> {creatingFromTemplate ? 'Assign' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="next-btn" onclick={() => (step = 2)}>
|
||||||
|
Next <ArrowRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit">{creatingFromTemplate ? 'Assign' : 'Save'}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -721,11 +919,23 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.template-list {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.template-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
.kanban {
|
.kanban {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-width: 450px;
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.kanban {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.column {
|
.column {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
@@ -742,8 +952,40 @@
|
|||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
.col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.col-header .col-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
display: inline-flex;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.chev.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.column.collapsed {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.column.collapsed .col-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.card-head {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -999,6 +1241,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.modal-actions button {
|
.modal-actions button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@@ -1006,12 +1249,17 @@
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
.modal-actions button.nav-btn,
|
||||||
.modal-actions button[type='button'] {
|
.modal-actions button[type='button'] {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
.modal-actions button[type='submit'] {
|
.modal-actions button.next-btn,
|
||||||
|
.modal-actions button[type='submit'].save-btn {
|
||||||
background: #6366f1;
|
background: #6366f1;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -1027,6 +1275,52 @@
|
|||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
/* Step tabs (Who / How / What) */
|
||||||
|
.step-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.step-tab {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.step-tab.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.step-tab :global(svg) {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.field-label :global(svg) {
|
||||||
|
flex: none;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
.step-body {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
.start-mode {
|
.start-mode {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -1046,5 +1340,19 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
/* Progress-counts date inputs must shrink rather than blow the label width. */
|
||||||
|
.start-mode input[type='date'] {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.step-body input[type='date'] {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.radio-group label input[type='date'] {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button } from '$lib/components';
|
||||||
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
const famSlug = page.params.fam;
|
const famSlug = page.params.fam;
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||||
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
||||||
🏠
|
<span class="home-badge">{@html homeIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
@@ -63,3 +64,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home-badge :global(svg) {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button } from '$lib/components';
|
||||||
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
const famSlug = page.params.fam;
|
const famSlug = page.params.fam;
|
||||||
const username = page.params.username;
|
const username = page.params.username;
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||||
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
||||||
🏠
|
<span class="home-badge">{@html homeIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
@@ -55,3 +56,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<style>
|
||||||
|
.home-badge :global(svg) {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||||
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
|
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import { homeIcon, chevronRightIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -77,15 +78,11 @@
|
|||||||
};
|
};
|
||||||
showTplModal = true;
|
showTplModal = true;
|
||||||
}
|
}
|
||||||
const allTemplates = $derived([
|
|
||||||
...(data.choreTemplates || []).map((t: any) => ({ ...t, kind: 'chore' as TplKind })),
|
|
||||||
...(data.bonusTemplates || []).map((t: any) => ({ ...t, kind: 'reward' as TplKind }))
|
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a>
|
<a class="brand" href="/"><span class="brand-icon">{@html homeIcon}</span> <strong>FamChore</strong></a>
|
||||||
<nav>
|
<nav>
|
||||||
{#if data.authenticated}
|
{#if data.authenticated}
|
||||||
<form method="POST" action="?/logout" use:enhance>
|
<form method="POST" action="?/logout" use:enhance>
|
||||||
@@ -311,7 +308,7 @@
|
|||||||
<span class="badge none">None</span>
|
<span class="badge none">None</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View →</a></td>
|
<td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View {@html chevronRightIcon}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -320,34 +317,30 @@
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={3} title="Templates" accent="#8b5cf6">
|
<Card cols={2} title="Chore Templates" accent="#2563eb">
|
||||||
<p class="muted">
|
|
||||||
Platform-owned chore & reward droplets. Families assign these as read-only sources — they
|
|
||||||
can't create their own.
|
|
||||||
</p>
|
|
||||||
<div class="tpl-grid">
|
<div class="tpl-grid">
|
||||||
{#each allTemplates as t}
|
{#each (data.choreTemplates || []) as t}
|
||||||
<div class="tpl-card" style="border-left-color:{t.color}">
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||||
<div class="tpl-head">
|
<div class="tpl-head">
|
||||||
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||||
<strong>{t.name}</strong>
|
<strong>{t.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-meta">
|
<div class="tpl-meta">
|
||||||
<span class="badge">{t.kind}</span>
|
<span class="badge chore">{t.defaultFrequency}</span>
|
||||||
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
<span class="badge">{t.defaultType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-actions">
|
<div class="tpl-actions">
|
||||||
<button class="link-btn" onclick={() => openEditTpl(t.kind, t)}>Edit</button>
|
<button class="link-btn" onclick={() => openEditTpl('chore', t)}>Edit</button>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteTemplate"
|
action="?/deleteTemplate"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ update }) => {
|
||||||
update();
|
update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="kind" value={t.kind} />
|
<input type="hidden" name="kind" value="chore" />
|
||||||
<input type="hidden" name="id" value={t.id} />
|
<input type="hidden" name="id" value={t.id} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -360,12 +353,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if allTemplates.length === 0}
|
{#if (data.choreTemplates || []).length === 0}
|
||||||
<p class="muted">No templates yet.</p>
|
<p class="muted">No chore templates yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-new">
|
<div class="tpl-new">
|
||||||
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card cols={1} title="Reward Templates" accent="#8b5cf6">
|
||||||
|
<div class="tpl-grid">
|
||||||
|
{#each (data.bonusTemplates || []) as t}
|
||||||
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||||
|
<div class="tpl-head">
|
||||||
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||||
|
<strong>{t.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-meta">
|
||||||
|
<span class="badge reward">{t.type}</span>
|
||||||
|
<span class="badge">{t.rewardType}</span>
|
||||||
|
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-actions">
|
||||||
|
<button class="link-btn" onclick={() => openEditTpl('reward', t)}>Edit</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="kind" value="reward" />
|
||||||
|
<input type="hidden" name="id" value={t.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="link-btn danger"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm('Delete this template?')) e.preventDefault();
|
||||||
|
}}>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if (data.bonusTemplates || []).length === 0}
|
||||||
|
<p class="muted">No reward templates yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-new">
|
||||||
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -978,6 +1016,8 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
.badge.chore { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge.reward { background: #ede9fe; color: #6d28d9; }
|
||||||
.badge.pocket { background: #dcfce7; color: #166534; }
|
.badge.pocket { background: #dcfce7; color: #166534; }
|
||||||
.check { display: flex; align-items: center; gap: 0.4rem; }
|
.check { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ export const actions = {
|
|||||||
|
|
||||||
let authResult: { token: string; record: any };
|
let authResult: { token: string; record: any };
|
||||||
try {
|
try {
|
||||||
authResult = await createPbClient()
|
authResult = await createPbClient().collection('users').authWithPassword(email, password);
|
||||||
.collection('users')
|
|
||||||
.authWithPassword(email, password);
|
|
||||||
} catch {
|
} catch {
|
||||||
return fail(400, { error: 'Invalid email or password', email });
|
return fail(400, { error: 'Invalid email or password', email });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<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>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -63,7 +63,9 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:hover { background: #3730a3; }
|
button:hover {
|
||||||
|
background: #3730a3;
|
||||||
|
}
|
||||||
.form-error {
|
.form-error {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
@@ -79,5 +81,9 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
.alt a {
|
||||||
|
color: #4338ca;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
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 Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import { homeIcon, chevronLeftIcon, chevronRightIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@
|
|||||||
|
|
||||||
// use:enhance is two-stage: this factory receives submit params (no result
|
// use:enhance is two-stage: this factory receives submit params (no result
|
||||||
// yet), and the RESOLVE function it returns receives { result, formData }.
|
// yet), and the RESOLVE function it returns receives { result, formData }.
|
||||||
const handleChoose = () =>
|
const handleChoose =
|
||||||
|
() =>
|
||||||
async ({ result, formData }: any) => {
|
async ({ result, formData }: any) => {
|
||||||
if (result?.type === 'success' && result.data) {
|
if (result?.type === 'success' && result.data) {
|
||||||
const d = result.data as { clientSecret?: string };
|
const d = result.data as { clientSecret?: string };
|
||||||
@@ -58,7 +60,9 @@
|
|||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a>
|
<a class="brand" href="/"
|
||||||
|
><span class="brand-icon">{@html homeIcon}</span> <strong>FamChore</strong></a
|
||||||
|
>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/login" class="nav-link">Log in</a>
|
<a href="/login" class="nav-link">Log in</a>
|
||||||
<a href="/signup" class="nav-cta">Get started</a>
|
<a href="/signup" class="nav-cta">Get started</a>
|
||||||
@@ -77,13 +81,17 @@
|
|||||||
<div class="checkout-card">
|
<div class="checkout-card">
|
||||||
<div class="checkout-head">
|
<div class="checkout-head">
|
||||||
<h2>Checkout — {checkoutTitle}</h2>
|
<h2>Checkout — {checkoutTitle}</h2>
|
||||||
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
<Button variant="ghost" size="sm" onclick={resetCheckout}
|
||||||
|
>{@html chevronLeftIcon} Back to plans</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div bind:this={checkoutEl} class="checkout-host"></div>
|
<div bind:this={checkoutEl} class="checkout-host"></div>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
You can close and go to your dashboard any time — access unlocks once payment completes.
|
You can close and go to your dashboard any time — access unlocks once payment completes.
|
||||||
</p>
|
</p>
|
||||||
<a href={data.famSlug ? `/${data.famSlug}` : '/'} class="dash-link">Go to dashboard →</a>
|
<a href={data.famSlug ? `/${data.famSlug}` : '/'} class="dash-link"
|
||||||
|
>Go to dashboard {@html chevronRightIcon}</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": "1.8.1"
|
"version": "1.8.6"
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -184,7 +184,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
{
|
{
|
||||||
name: "settings",
|
name: "settings",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("settings", [rel("famId", ids.fams, true), text("webhookUrl"), bool("simulateEow")], {
|
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], {
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
@@ -229,6 +229,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||||
select("status", ["active", "completed"], true),
|
select("status", ["active", "completed"], true),
|
||||||
bool("isPocketMoney"),
|
bool("isPocketMoney"),
|
||||||
|
// Standalone/one-off reward progress window. `period` stays empty for
|
||||||
|
// these (they're measured once, not per-cycle). `completeBy` bounds how
|
||||||
|
// far progress counts: `unlimited` (cumulative since startDate),
|
||||||
|
// `week` (end of current week), or `custom` (a specific completeByDate).
|
||||||
|
// `startDate` is when tracking begins — without it "unlimited" would
|
||||||
|
// count points earned before the reward existed.
|
||||||
|
select("completeBy", ["unlimited", "week", "custom"]),
|
||||||
|
text("startDate"),
|
||||||
|
text("completeByDate"),
|
||||||
], {
|
], {
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
@@ -339,7 +348,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
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),
|
||||||
number("value", true),
|
number("value", false),
|
||||||
text("customName"),
|
text("customName"),
|
||||||
text("description"),
|
text("description"),
|
||||||
text("icon"),
|
text("icon"),
|
||||||
|
|||||||
@@ -104,6 +104,43 @@ export function periodEnd(period: string, start: string): string {
|
|||||||
return start;
|
return start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute the completion-counting window for a bonus. Recurring period-based
|
||||||
|
// bonuses keep their per-cycle window (periodStart..periodEnd). Standalone /
|
||||||
|
// one-off rewards (no `period`) are scoped by `completeBy` + `startDate`:
|
||||||
|
// - `completeBy === "week"` → current week [weekStart, weekEnd]
|
||||||
|
// - `completeBy === "custom"` → [startDate, completeByDate]
|
||||||
|
// - otherwise (`unlimited`/empty) → [startDate, ∞] (cumulative)
|
||||||
|
// `from`/`to` of "" mean unbounded on that side (legacy configs with no dates
|
||||||
|
// count everything since records began, i.e. the admin-dashboard behaviour).
|
||||||
|
export function bonusWindow(
|
||||||
|
cfg: { period?: string; completeBy?: string; startDate?: string; completeByDate?: string },
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): { from: string; to: string } {
|
||||||
|
if (cfg.period) {
|
||||||
|
const start = periodStart(cfg.period, payday, tz);
|
||||||
|
return { from: start, to: periodEnd(cfg.period, start) };
|
||||||
|
}
|
||||||
|
const from = cfg.startDate || "";
|
||||||
|
let to = "";
|
||||||
|
if (cfg.completeBy === "week") to = periodEnd("weekly", weekStart(payday, tz));
|
||||||
|
else if (cfg.completeBy === "custom") to = cfg.completeByDate || "";
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if a completion record's date falls within the window (dates may be
|
||||||
|
// YYYY-MM-DD or ISO with a time portion).
|
||||||
|
export function completionInWindow(
|
||||||
|
c: { date?: string },
|
||||||
|
win: { from: string; to: string },
|
||||||
|
): boolean {
|
||||||
|
const day = (c.date || "").slice(0, 10);
|
||||||
|
if (!day) return false;
|
||||||
|
if (win.from && day < win.from) return false;
|
||||||
|
if (win.to && day > win.to) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// The completion window a chore of a given frequency is "done for" in the
|
// The completion window a chore of a given frequency is "done for" in the
|
||||||
// current period. Weekly chores are done once per week ([weekStart, +7)),
|
// current period. Weekly chores are done once per week ([weekStart, +7)),
|
||||||
// daily chores once per day ([today, +1)). Half-open [from, to).
|
// daily chores once per day ([today, +1)). Half-open [from, to).
|
||||||
|
|||||||
Reference in New Issue
Block a user