fixed payout locks & time sensitive todos & minor ui updates on chores view

This commit is contained in:
JCEEE
2026-08-04 11:38:38 +01:00
parent 7603c3eb21
commit c38ca43f0b
11 changed files with 289 additions and 164 deletions
+2
View File
@@ -200,11 +200,13 @@ All admin and member pages use the following pattern:
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server - **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono - **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix - **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix
- **Dates**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Use the shared `formatDDMMYY()` helper in `frontend/src/lib/format.ts`. Never render raw `YYYY-MM-DD` to users. Exception: single human-readable dates like todo **due dates** should use `formatShortDate()` (also in `format.ts`, renders `5 Aug` / `5 Aug 26`) — the compact DDMMYY code is ambiguous and bad UI for those.
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars - `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars
- `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template - `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template
- Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase) - Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase)
- Nginx routes in prod: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`) - Nginx routes in prod: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`)
- Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved) - Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved)
- **Dev servers: NEVER start your own.** Always reuse the running dev servers — proxy `192.168.1.225:3456` (tsx watch, reloads on edit), frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev` / `tsx watch` / extra vite instances. Only restart when the user explicitly asks.
- Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL` - Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`
- Seed via JSON dump (portable for dev) - Seed via JSON dump (portable for dev)
- Monorepo: SvelteKit in `frontend/`, Hono in `proxy/`, two Dockerfiles - Monorepo: SvelteKit in `frontend/`, Hono in `proxy/`, two Dockerfiles
+41
View File
@@ -110,3 +110,44 @@
- **daysLeft now counts to settlement day**: `daysLeft` on the child dashboard counts to `weekStart + 7` (the next payday day) instead of `weekEnd = weekStart + 6`. So Tue Aug 4 with payday=Sun shows 5 days until payday. Hero label updated to "days until payday". - **daysLeft now counts to settlement day**: `daysLeft` on the child dashboard counts to `weekStart + 7` (the next payday day) instead of `weekEnd = weekStart + 6`. So Tue Aug 4 with payday=Sun shows 5 days until payday. Hero label updated to "days until payday".
- **User-facing renames**: hero `days left``days until payday`; debug card `Simulate End-of-Week``Simulate Payday`; error `Failed to preview EOW``Failed to preview payday`; settings hint reworded to lead with "Payday:". - **User-facing renames**: hero `days left``days until payday`; debug card `Simulate End-of-Week``Simulate Payday`; error `Failed to preview EOW``Failed to preview payday`; settings hint reworded to lead with "Payday:".
### 2026-08-04 — DDMMYY Date Rule + Debug Payday Preview Fix
- **Rule added (AGENTS.md)**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Shared helper `formatDDMMYY()` in `frontend/src/lib/format.ts` (extracted from the local copy in `chores/+page.svelte`). Never render raw `YYYY-MM-DD` to users.
- **Bug**: the admin "Preview payday" card showed all-time totals (e.g. "280 pts £288.00 14 chores" for zooney) because the proxy's `eow-preview` reward queries (`rewardPointsList`/`rewardCashList`) had NO date filter, summing every claimed reward ever. `complete-week` (the real settlement snapshot) scopes with `date >= ws`.
- **Fix**: added `&& date >= '${ws}'` to both reward queries in `eow-preview` (`proxy/src/index.ts`) so the preview matches what the rollover actually records. Verified live: zooney now shows this-week `220 pts / £16.00 / 14 chores / bonus 10`.
- **UI**: removed the no-op **Simulation ON/OFF** toggle (settings.simulateEow flag drives no behavior) — it was the source of "simulation on/off vs preview rollover" confusion. Card is now just "Debug: Preview payday" → "Preview payday" button. Debug card dates now render via `formatDDMMYY`.
- **Typecheck**: frontend `svelte-check` still at 12 baseline errors (no new); proxy `tsc` unchanged pre-existing baseline.
### 2026-08-04 — Preview Payday Extends to Child Dashboard
- **Feature**: "Preview payday" now enables a family-wide preview mode that the child dashboard reacts to. `?/previewEow` action calls `eowPreview` then `hono.admin.updateSettings({ simulateEow: true })`, returning `{ preview, simulateEow: true }`. A "Turn off preview" button (`?/setEow`, `on=false`) clears it.
- **Child notice**: `my-chores` (`proxy/src/index.ts`) now returns `simulateEow: !!settings.simulateEow`; child `+page.server.ts` passes it as `data.simulateEow`. Child kanban renders a `.preview-notice` banner ("Payday preview — your parent is checking this week's payday. Nothing is paid out yet.") when the flag is set. Admin card shows a `.eow-mode-on` note + "Turn off preview" when active.
- **Note**: `simulateEow` (settings.simulateEow) was previously a no-op debug flag; it now meaningfully drives preview mode across admin + child views.
- **Verified live**: POST `?/previewEow` sets `settings.simulateEow=true` (GET settings confirms); child SSR page data carries `simulateEow:true`; control case (flag off) renders no notice. Test data restored afterwards (zooney deviceToken + flag reset to false).
### 2026-08-04 — Payday-Gated Bonus Payouts
- **Feature**: weekly/monthly period bonus rewards are now **claimable only on payday** (not the moment they're met). Rewards gain `claimable: 'immediate' | 'payday'` + `settleDate` (YYYY-MM-DD, server-side). The bonus-met notice stays exciting on the child dash — the reward line shows a locked "🔒 pays out {DDMMYY}" badge and skips the request button pre-payday.
- **Stamp logic**: `claimableStamp()` in `proxy/src/index.ts` — periods `weekly`/`monthly``{ claimable: 'payday', settleDate: nextPaydayAfter(periodEnd) }`; else `immediate`. Manual bonus triggers (`/bonus-configs/:id/trigger`) stamp `immediate` (parent-initiated, not a scheduled payout). All 3 `evaluateFam` create sites (individual/collaborative/competitive) use `claimableStamp`. `nextPaydayAfter()` helper added to `timezone.ts`.
- **Enforcement**: member `claim` endpoint checks `assertPaydayUnlocked()` (throws `"This bonus pays out on payday (…settleDate) — hang tight!"`, returned as HTTP 400); `request-all` skips payday-gated rewards not yet settled. Admin `Issue`/`Issue All` are parent discretion and unaffected.
- **UI**: child wallet renders locked badge for pre-settle payday rewards; admin "Claims → Outstanding" shows a `🔒 {DDMMYY}` hint. Both reuse `formatDDMMYY()`. (Later: switched both to `formatShortDate()` → "🔒 pays out 9 Aug"; child `owedCash` banner excludes payday-locked rewards so "You've earned £X — go get it!" no longer shows for rewards that aren't claimable yet.)
- **Schema**: `rewards.claimable` (select, required) + `rewards.settleDate` (text) added in `proxy/src/migrate.ts` + `proxy/scripts/seed.ts`. PB's `required` select rejects empty on write; legacy null-claimable rewards are treated as `immediate` by both proxy and frontend, so no data backfill was needed.
- **Verified live**: `complete-week``evaluateFam` recreated the weekly Pocket Money reward with `claimable=payday, settleDate=2026-08-09` (Sunday payday after Sun→Sat week); member claim pre-payday → 400 with friendly message + status stays `unclaimed`; `request-all` returns `{count:0}`; claim succeeds after settleDate.
- **Ops note**: the dev proxy's `tsx watch` had silently frozen (file edits at 09:49 weren't picked up by a child started 09:47). Fixed by killing the watcher tree with explicit PIDs and relaunching `pnpm dev` (nohup → `/tmp/proxy_dev.log`). `pkill -f "tsx watch src/index.ts"` hangs the shell — use `kill <pid>` instead.
### 2026-08-04 — Dev Servers: Always Reuse Existing 2080/3456
- **Rule**: NEVER start our own dev servers. Always use the already-running ones: proxy `192.168.1.225:3456` (tsx watch, reloads on edit) and frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev`, `tsx watch`, or extra vite instances — it wastes time/tokens. Only kill/restart when the user explicitly asks (or a watcher is demonstrably stale, and then only after asking). Prefer short targeted curls and reuse one auth `TOKEN` across commands in the persistent shell.
### 2026-08-04 — Chores Page Accordion Quick Fixes
- **Add actions moved into sections**: removed the blue round `+` from the member swimlane header and the Templates column header. Both replaced by a shared full-width dashed ` Add a todo` / ` New template` button (`.add-inline`) at the top of the Todos accordion content and the Templates list respectively.
- **Accordions default open**: `accordionState` lookup defaults to `{ chores: true, todos: true }` (`?? true` in the template + toggle), so both sections load expanded on page load; still toggleable. Redundant empty-state "+ Add a todo" button and `.add-todo-btn`/`.empty-cta` CSS removed.
- **Check**: frontend `svelte-check` stays at 12 baseline errors.
### 2026-08-04 — Human Dates for Todo "Due" (Not DDMMYY)
- **Problem**: the chores todo card rendered `due 050826` (DDMMYY code) — ambiguous/terrible for a due date.
- **Fix**: added `formatShortDate()` to `frontend/src/lib/format.ts` — renders `5 Aug` (adds ` 26` when the year isn't the current one). Chores todo card now shows `due 5 Aug`. AGENTS.md date rule updated: DDMMYY for dense/range contexts, `formatShortDate()` for single human-readable dates like due dates.
+2
View File
@@ -138,6 +138,8 @@ export interface Reward {
value: number; value: number;
rewardType: BonusRewardType; rewardType: BonusRewardType;
status: 'unclaimed' | 'requested' | 'claimed'; status: 'unclaimed' | 'requested' | 'claimed';
claimable?: 'immediate' | 'payday';
settleDate?: string;
claimedAt?: string; claimedAt?: string;
requestedAt?: string; requestedAt?: string;
date: string; date: string;
@@ -120,7 +120,8 @@ export async function load(event) {
tallies: chores.tallies || {}, tallies: chores.tallies || {},
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 { return {
@@ -161,7 +162,8 @@ export const actions = {
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
try { try {
const preview = await hono.admin.eowPreview(event, famId); const preview = await hono.admin.eowPreview(event, famId);
return { preview }; await hono.admin.updateSettings(event, famId, { simulateEow: true });
return { preview, simulateEow: true };
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to preview payday' }; return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
} }
@@ -4,6 +4,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { memberApi } from '$lib/client/api'; import { memberApi } from '$lib/client/api';
import { formatDDMMYY, formatShortDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types'; import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import { import {
@@ -40,9 +41,8 @@
let summary = $state(data.summary); let summary = $state(data.summary);
let today = $derived(todayInTz(famTz)); let today = $derived(todayInTz(famTz));
let simulateEow = $state(!!data.settings?.simulateEow); let simulateEow = $state(!!(data.settings?.simulateEow ?? data.simulateEow));
let eowPreview = $state<any>(null); let eowPreview = $state<any>(null);
const responseTags = [ const responseTags = [
'👏 well done', '👏 well done',
'😊 really pleased', '😊 really pleased',
@@ -352,9 +352,17 @@
.reduce((sum, r) => sum + Number(r.value), 0) .reduce((sum, r) => sum + Number(r.value), 0)
); );
// Money owed to this member across all time (incl. carry-over from past weeks). // Money owed to this member across all time (incl. carry-over from past weeks).
// Payday-gated rewards are excluded until their settleDate — the wallet shows
// them separately as a locked "pays out on payday" line.
let owedCash = $derived.by(() => let owedCash = $derived.by(() =>
rewards rewards
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status !== 'claimed') .filter(
(r) =>
r.memberId === memberId &&
r.rewardType === 'cash' &&
r.status !== 'claimed' &&
!paydayLocked(r)
)
.reduce((sum, r) => sum + Number(r.value), 0) .reduce((sum, r) => sum + Number(r.value), 0)
); );
let weekPointsEarned = $derived.by(() => let weekPointsEarned = $derived.by(() =>
@@ -370,6 +378,10 @@
(r.status === 'unclaimed' || r.status === 'requested') (r.status === 'unclaimed' || r.status === 'requested')
) )
); );
// Payday-gated bonus rewards unlock on their settleDate (stamped server-side).
function paydayLocked(r: any) {
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate;
}
let weekBonusTallies = $derived.by(() => { let weekBonusTallies = $derived.by(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const r of weekRewards) { for (const r of weekRewards) {
@@ -786,7 +798,12 @@
{#each outstanding as r} {#each outstanding as r}
<div class="payment-row outstanding"> <div class="payment-row outstanding">
<span>{r.label}</span> <span>{r.label}</span>
<span class="value">{rewardLabel(r)}</span> <span class="value">
{rewardLabel(r)}
{#if paydayLocked(r)}
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
{/if}
</span>
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -822,28 +839,10 @@
</Card> </Card>
</CardGrid> </CardGrid>
<CardGrid> <CardGrid>
<Card cols={3} title="Debug: Simulate Payday" accent="#f59e0b"> <Card cols={3} title="Debug: Preview payday" accent="#f59e0b">
<p class="eow-desc"> <p class="eow-desc">
Read-only preview of what the payday rollover will produce. Nothing here is written. Read-only preview of what the payday rollover will settle for this week. Nothing here is
</p> written.
<form
method="POST"
action="?/setEow"
use:enhance={() => {
return async ({ result }) => {
const d = (result as any).data || {};
if (d.error) toast = d.error;
else simulateEow = !!d.simulateEow;
};
}}
>
<input type="hidden" name="on" value={simulateEow ? 'false' : 'true'} />
<button type="submit" class="eow-switch" class:on={simulateEow}>
{simulateEow ? 'Simulation ON' : 'Simulation OFF'}
</button>
</form>
<p class="eow-note">
This toggles the family debug flag only. It does not alter any live data.
</p> </p>
<form <form
@@ -853,19 +852,44 @@
return async ({ result }) => { return async ({ result }) => {
const d = (result as any).data || {}; const d = (result as any).data || {};
if (d.error) toast = d.error; if (d.error) toast = d.error;
else eowPreview = d.preview; else {
eowPreview = d.preview;
simulateEow = !!d.simulateEow;
}
}; };
}} }}
> >
<Button type="submit" size="sm" variant="secondary">Preview rollover</Button> <Button type="submit" size="sm" variant="secondary">Preview payday</Button>
</form> </form>
{#if simulateEow}
<p class="eow-mode-on">
👀 Preview mode is ON — child dashboards show a "payday preview" notice.
</p>
<form
method="POST"
action="?/setEow"
use:enhance={() => {
return async ({ result }) => {
const d = (result as any).data || {};
if (d.error) toast = d.error;
else simulateEow = !!d.simulateEow;
};
}}
>
<input type="hidden" name="on" value="false" />
<Button type="submit" size="sm" variant="ghost">Turn off preview</Button>
</form>
{/if}
{#if eowPreview} {#if eowPreview}
<div class="eow-out"> <div class="eow-out">
<p class="eow-range">Week {eowPreview.weekStart}{eowPreview.weekEnd}</p> <p class="eow-range">
Week {formatDDMMYY(eowPreview.weekStart)}{formatDDMMYY(eowPreview.weekEnd)}
</p>
<p class="eow-reset"> <p class="eow-reset">
On rollover these {eowPreview.completionsThisWeek} completions reset; week starts anew at On payday these {eowPreview.completionsThisWeek} completions reset; week starts anew at
{eowPreview.nextWeekStart}. {formatDDMMYY(eowPreview.nextWeekStart)}.
</p> </p>
<div class="eow-summaries"> <div class="eow-summaries">
@@ -904,6 +928,12 @@
{:else if error} {:else if error}
<p class="error">{error}</p> <p class="error">{error}</p>
{:else} {:else}
{#if simulateEow}
<div class="preview-notice">
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
out yet.
</div>
{/if}
{#if owedCash > 0} {#if owedCash > 0}
<div class="payday-banner"> <div class="payday-banner">
🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent! 🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent!
@@ -1240,6 +1270,10 @@
<span class="wr-pending">✓ credited</span> <span class="wr-pending">✓ credited</span>
{:else if isReq} {:else if isReq}
<span class="wr-pending">⏳ waiting</span> <span class="wr-pending">⏳ waiting</span>
{:else if paydayLocked(r)}
<span class="wr-pending wr-locked"
>🔒 pays out {formatShortDate(r.settleDate)}</span
>
{:else} {:else}
<button <button
class="wr-cta" class="wr-cta"
@@ -2020,6 +2054,15 @@
font-weight: 600; font-weight: 600;
color: #6b7280; color: #6b7280;
} }
.wr-locked {
color: #d97706;
}
.owe-locked {
margin-left: 0.35rem;
font-size: 0.72rem;
font-weight: 600;
color: #d97706;
}
.wr-cta { .wr-cta {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
@@ -2038,6 +2081,30 @@
margin: 0 0 0.6rem; margin: 0 0 0.6rem;
} }
.eow-mode-on {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0;
padding: 0.6rem 0.8rem;
border: 1px dashed #f59e0b;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
font-size: 0.85rem;
}
.preview-notice {
margin-bottom: 1rem;
padding: 0.85rem 1.1rem;
border-radius: 12px;
border: 1px dashed #f59e0b;
background: #fffbeb;
color: #92400e;
font-size: 0.95rem;
line-height: 1.4;
}
.payday-banner { .payday-banner {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2092,25 +2159,6 @@
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.85; opacity: 0.85;
} }
.eow-switch {
font-size: 0.85rem;
font-weight: 700;
padding: 0.45rem 1rem;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
cursor: pointer;
}
.eow-switch.on {
background: #f59e0b;
color: #fff;
}
.eow-note {
font-size: 0.75rem;
color: #9ca3af;
margin: 0.4rem 0 0.6rem;
}
.eow-out { .eow-out {
margin-top: 0.75rem; margin-top: 0.75rem;
border-top: 1px dashed #e5e7eb; border-top: 1px dashed #e5e7eb;
@@ -425,24 +425,6 @@
</div> </div>
</div> </div>
{#if doneConfigs.length > 0}
<div class="completed-section">
<h3>Completed</h3>
<div class="completed-grid">
{#each doneConfigs as cfg}
<div class="completed-card">
<div class="card-head">
<strong>{cfg.name}</strong>
</div>
<div class="reward-preview">
{formatReward(cfg.rewardType, cfg.rewardValue)}
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if showCreateModal} {#if showCreateModal}
<div class="overlay" onclick={() => (showCreateModal = false)} role="presentation"> <div class="overlay" onclick={() => (showCreateModal = false)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
@@ -1022,25 +1004,4 @@
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
} }
.completed-section {
margin-top: 2rem;
border-top: 2px solid #10b981;
padding-top: 1rem;
}
.completed-section h3 {
font-size: 1rem;
margin: 0 0 0.75rem;
}
.completed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.completed-card {
background: #f0fdf4;
border: 2px solid #10b981;
border-radius: 0.5rem;
padding: 0.5rem;
opacity: 0.75;
}
</style> </style>
@@ -3,6 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
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 { formatShortDate } from '$lib/format';
import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types'; import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types';
let { data, form } = $props(); let { data, form } = $props();
@@ -143,7 +144,7 @@
let accordionState = $state<Record<string, { chores: boolean; todos: boolean }>>({}); let accordionState = $state<Record<string, { chores: boolean; todos: boolean }>>({});
function toggleAccordion(memberId: string, section: 'chores' | 'todos') { function toggleAccordion(memberId: string, section: 'chores' | 'todos') {
const cur = accordionState[memberId] ?? { chores: false, todos: false }; const cur = accordionState[memberId] ?? { chores: true, todos: true };
accordionState[memberId] = { ...cur, [section]: !cur[section] }; accordionState[memberId] = { ...cur, [section]: !cur[section] };
} }
@@ -168,16 +169,6 @@
}); });
} }
// Format DDMMYY for todo deadline display
function formatDDMMYY(dateStr: string | undefined): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = String(d.getMonth() + 1).padStart(2, '0');
const yr = String(d.getFullYear()).slice(2);
return `${day}${mon}${yr}`;
}
// 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
function defaultCompleteByDate(): string { function defaultCompleteByDate(): string {
const d = new Date(); const d = new Date();
@@ -314,14 +305,8 @@
<Card cols={3}> <Card cols={3}>
<div class="kanban"> <div class="kanban">
<div class="column templates-col"> <div class="column templates-col">
<h3> <h3>Templates</h3>
Templates <button class="add-inline" onclick={() => (showCreateModal = true)}> New template</button>
<button
class="add-todo-btn"
title="New Chore Template"
onclick={() => (showCreateModal = true)}>+</button
>
</h3>
{#each templates as t} {#each templates as t}
<div class="card template" draggable="true" ondragstart={(e) => handleDragStart(e, t.id)}> <div class="card template" draggable="true" ondragstart={(e) => handleDragStart(e, t.id)}>
<div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0"> <div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0">
@@ -366,22 +351,22 @@
ondragover={handleDragOver} ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)} ondrop={(e) => handleDrop(e, m.id)}
> >
<h3> <h3>
<span class="dot" style="background:{m.color}"></span> <span class="dot" style="background:{m.color}"></span>
{m.name} {m.name}
<button class="add-todo-btn" title="Add Todo" onclick={() => openTodo(m.id)}>+</button> </h3>
</h3>
<!-- TODOS accordion --> <!-- TODOS accordion -->
<button <button
class="accordion-head accordion-todos" class="accordion-head accordion-todos"
onclick={() => toggleAccordion(m.id, 'todos')} onclick={() => toggleAccordion(m.id, 'todos')}
> >
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span> <span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
<span class="accordion-arrow" class:open={accordionState[m.id]?.todos}> </span> <span class="accordion-arrow" class:open={accordionState[m.id]?.todos ?? true}> </span>
</button> </button>
{#if accordionState[m.id]?.todos} {#if accordionState[m.id]?.todos ?? true}
{#each sortedTodosForMember(m.id) as a} <button class="add-inline" onclick={() => openTodo(m.id)}> Add a todo</button>
{#each sortedTodosForMember(m.id) as a}
{@const urgency = todoUrgency(a)} {@const urgency = todoUrgency(a)}
<div <div
class="todo-admin-card" class="todo-admin-card"
@@ -403,7 +388,7 @@
<span class="todo-admin-type">{a.value} {a.type}</span> <span class="todo-admin-type">{a.value} {a.type}</span>
{/if} {/if}
{#if a.completeBy} {#if a.completeBy}
<span class="todo-admin-deadline">due {formatDDMMYY(a.completeBy)}</span> <span class="todo-admin-deadline">due {formatShortDate(a.completeBy)}</span>
{/if} {/if}
</div> </div>
</div> </div>
@@ -421,18 +406,15 @@
}}>×</button }}>×</button
> >
</div> </div>
{/each} {/each}
{#if todosForMember(m.id).length === 0} {/if}
<button class="empty empty-cta" onclick={() => openTodo(m.id)}>+ Add a todo</button>
{/if}
{/if}
<!-- CHORES accordion --> <!-- CHORES accordion -->
<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-label">Chores ({assignedForMember(m.id).length})</span>
<span class="accordion-arrow" class:open={accordionState[m.id]?.chores}> </span> <span class="accordion-arrow" class:open={accordionState[m.id]?.chores ?? true}> </span>
</button> </button>
{#if accordionState[m.id]?.chores} {#if accordionState[m.id]?.chores ?? true}
{#each assignedForMember(m.id) as a} {#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)} {@const tName = templateName(a.templateId)}
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0"> <div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
@@ -771,13 +753,19 @@
border: 1px dashed #d1d5db; border: 1px dashed #d1d5db;
border-radius: 6px; border-radius: 6px;
} }
.empty-cta { .add-inline {
width: 100%; width: 100%;
cursor: pointer; margin-bottom: 0.5rem;
padding: 0.4rem;
background: transparent; background: transparent;
font: inherit; border: 1px dashed #d1d5db;
border-radius: 6px;
color: #6b7280;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
} }
.empty-cta:hover { .add-inline:hover {
border-color: #6366f1; border-color: #6366f1;
color: #6366f1; color: #6366f1;
background: #eef2ff; background: #eef2ff;
@@ -1072,28 +1060,6 @@
font-weight: 700; font-weight: 700;
} }
/* ── Add Todo button ── */
.add-todo-btn {
margin-left: auto;
background: #6366f1;
color: #fff;
border: none;
border-radius: 50%;
width: 22px;
height: 22px;
font-size: 1rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
}
.add-todo-btn:hover {
background: #4f46e5;
}
.todo-badge { .todo-badge {
background: #fef3c7; background: #fef3c7;
color: #b45309; color: #b45309;
+2
View File
@@ -286,6 +286,8 @@ async function main() {
number("value", true), number("value", true),
select("rewardType", ["cash", "prize", "points"], true), select("rewardType", ["cash", "prize", "points"], true),
select("status", ["unclaimed", "requested", "claimed"], true), select("status", ["unclaimed", "requested", "claimed"], true),
select("claimable", ["immediate", "payday"], true),
text("settleDate"),
date("claimedAt"), date("claimedAt"),
date("requestedAt"), date("requestedAt"),
text("date"), text("date"),
+57 -2
View File
@@ -9,6 +9,7 @@ import {
todayInTz, todayInTz,
wallClockToUtc, wallClockToUtc,
resolveTz, resolveTz,
nextPaydayAfter as nextPaydayAfterTz,
} from "../../timezone.ts"; } from "../../timezone.ts";
const app = new Hono(); const app = new Hono();
@@ -69,6 +70,35 @@ function periodEnd(period: string, start: string): string {
return start; return start;
} }
function nextPaydayAfter(dateStr: string, payday: number, tz?: string): string {
return nextPaydayAfterTz(dateStr, payday, resolveServerTz(tz));
}
// Rewards from weekly/monthly bonus configs are claimable only on payday.
// Stamp the reward with a settleDate = the next payday after the period ends.
function claimableStamp(cfg: any, payday: number, tz?: string) {
if (cfg.period !== "weekly" && cfg.period !== "monthly") {
return { claimable: "immediate", settleDate: "" };
}
const tzR = resolveServerTz(tz);
const now = todayInTz(tzR);
const start =
cfg.period === "monthly" ? `${now.slice(0, 7)}-01` : weekStart(payday, tzR);
const end = periodEnd(cfg.period, start);
return { claimable: "payday", settleDate: nextPaydayAfter(end, payday, tzR) };
}
// Payday-gated rewards can't be claimed until their settleDate (the payday).
function assertPaydayUnlocked(reward: any, tz?: string) {
if (!reward || reward.claimable !== "payday" || !reward.settleDate) return;
const today = todayInTz(resolveServerTz(tz));
if (today < reward.settleDate) {
throw new Error(
`This bonus pays out on payday (${reward.settleDate}) — hang tight!`,
);
}
}
async function getFamPayday(famId: string): Promise<number> { async function getFamPayday(famId: string): Promise<number> {
try { try {
const fam = await pb.getList("fams", `id = '${famId}'`); const fam = await pb.getList("fams", `id = '${famId}'`);
@@ -809,11 +839,11 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
const [pw, cw] = await Promise.all([ const [pw, cw] = await Promise.all([
pb.getList( pb.getList(
"rewards", "rewards",
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`, `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`,
), ),
pb.getList( pb.getList(
"rewards", "rewards",
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`, `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`,
), ),
]); ]);
rewardPointsList = pw.items; rewardPointsList = pw.items;
@@ -1327,6 +1357,7 @@ async function evaluateFam(famId: string): Promise<void> {
status: cfg.rewardType === "points" ? "claimed" : "unclaimed", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval),
}); });
createdReward = true; createdReward = true;
} }
@@ -1377,6 +1408,7 @@ async function evaluateFam(famId: string): Promise<void> {
status: cfg.rewardType === "points" ? "claimed" : "unclaimed", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval),
}); });
createdReward = true; createdReward = true;
} }
@@ -1434,6 +1466,7 @@ async function evaluateFam(famId: string): Promise<void> {
status: cfg.rewardType === "points" ? "claimed" : "unclaimed", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval),
}); });
createdReward = true; createdReward = true;
} }
@@ -1576,6 +1609,8 @@ app.post(
status: cfg.rewardType === "points" ? "claimed" : "unclaimed", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), date: now.slice(0, 10),
claimable: "immediate",
settleDate: "",
}); });
created.push(record); created.push(record);
} }
@@ -1690,6 +1725,7 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
const payday = await getFamPayday(famId); const payday = await getFamPayday(famId);
const paydayTime = await getFamPaydayTime(famId); const paydayTime = await getFamPaydayTime(famId);
const timezone = await getFamTimezone(famId); const timezone = await getFamTimezone(famId);
const settings = await getFamSettings(famId);
const rewardsList = rewards.items; const rewardsList = rewards.items;
const configsList = bonusConfigs.items; const configsList = bonusConfigs.items;
return c.json({ return c.json({
@@ -1701,6 +1737,7 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
payday, payday,
paydayTime, paydayTime,
timezone, timezone,
simulateEow: !!settings.simulateEow,
}); });
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
@@ -1921,6 +1958,18 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
const famId = c.get("famId"); const famId = c.get("famId");
const memberId = c.get("memberId"); const memberId = c.get("memberId");
const now = new Date().toISOString(); const now = new Date().toISOString();
const tz = await getFamTimezone(famId);
const found = await pb.getList("rewards", `famId = '${famId}' && id = '${id}'`);
const reward = found.items?.[0];
if (!reward) return c.json({ error: "Reward not found" }, 404);
try {
assertPaydayUnlocked(reward, tz);
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Not claimable yet" },
400,
);
}
const record = await pb.update("rewards", id, { const record = await pb.update("rewards", id, {
status: "requested", status: "requested",
requestedAt: now, requestedAt: now,
@@ -1951,6 +2000,7 @@ app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
const famId = c.get("famId"); const famId = c.get("famId");
const memberId = c.get("memberId"); const memberId = c.get("memberId");
const now = new Date().toISOString(); const now = new Date().toISOString();
const tz = await getFamTimezone(famId);
// Find all unclaimed rewards for this member // Find all unclaimed rewards for this member
const rewards = await pb.getList( const rewards = await pb.getList(
"rewards", "rewards",
@@ -1958,6 +2008,11 @@ app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
); );
let count = 0; let count = 0;
for (const r of rewards.items) { for (const r of rewards.items) {
try {
assertPaydayUnlocked(r, tz);
} catch {
continue; // payday-gated reward not yet settled — leave for payday
}
await pb.update("rewards", r.id, { await pb.update("rewards", r.id, {
status: "requested", status: "requested",
requestedAt: now, requestedAt: now,
+36
View File
@@ -284,6 +284,42 @@ export async function migrate(): Promise<void> {
console.log(` ↳ rewards collection not found (will be created by seed)`); console.log(` ↳ rewards collection not found (will be created by seed)`);
} }
// Ensure rewards.claimable + rewards.settleDate exist (payday-gated bonuses)
const rewardsCol2 = await getCollection("rewards");
if (rewardsCol2) {
const fieldNames = rewardsCol2.fields.map((f: any) => f.name);
const missing: any[] = [];
if (!fieldNames.includes("claimable")) {
missing.push({
name: "claimable",
type: "select",
required: true,
values: ["immediate", "payday"],
maxSelect: 1,
});
}
if (!fieldNames.includes("settleDate")) {
missing.push({ name: "settleDate", type: "text", required: false });
}
if (missing.length) {
console.log("[migrate] Adding rewards.claimable/settleDate (payday gating)...");
rewardsCol2.fields.push(...missing);
await updateCollection(rewardsCol2.id, {
name: "rewards",
type: "base",
listRule: rewardsCol2.listRule,
viewRule: rewardsCol2.viewRule,
createRule: rewardsCol2.createRule,
updateRule: rewardsCol2.updateRule,
deleteRule: rewardsCol2.deleteRule,
fields: rewardsCol2.fields,
});
console.log(" ✓ rewards.claimable/settleDate added");
} else {
console.log(` ↳ rewards.claimable/settleDate already exist`);
}
}
// ── 3. Update settings collection (drop old fields) ── // ── 3. Update settings collection (drop old fields) ──
const settingsCol = await getCollection("settings"); const settingsCol = await getCollection("settings");
if (settingsCol) { if (settingsCol) {
+10
View File
@@ -57,6 +57,16 @@ export function weekStart(payday: number, tz: string): string {
return addDaysStr(today, -back); return addDaysStr(today, -back);
} }
// The first configured-payday day strictly after dateStr (used to gate
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
export function nextPaydayAfter(dateStr: string, payday: number, tz: string): string {
let d = addDaysStr(dateStr, 1);
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
d = addDaysStr(d, 1);
}
return d;
}
function monthStartStr(): string { function monthStartStr(): string {
const d = new Date(); const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;