updated rewards assignment and reward progress
This commit is contained in:
@@ -321,6 +321,20 @@
|
||||
- **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.
|
||||
|
||||
### 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)
|
||||
|
||||
- `[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.
|
||||
|
||||
@@ -440,6 +440,25 @@ async function ensureBonusFields(): Promise<void> {
|
||||
fields.push({ name: "isPocketMoney", type: "bool", required: false });
|
||||
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.
|
||||
const rv = fields.find((f: any) => f.name === "rewardValue");
|
||||
if (rv && rv.required) {
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
nextPaydayAfter,
|
||||
weekStart
|
||||
weekStart,
|
||||
bonusWindow,
|
||||
completionInWindow
|
||||
} from '@shared/timezone';
|
||||
import { famMeta } from './fam';
|
||||
|
||||
@@ -55,13 +57,8 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
for (const cfg of configs) {
|
||||
// Pocket money pauses until the parent sets an amount.
|
||||
if (cfg.isPocketMoney && !cfg.rewardValue) continue;
|
||||
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : '';
|
||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
|
||||
const periodCompletions = cfg.period
|
||||
? allCompletions.filter(
|
||||
(c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd
|
||||
)
|
||||
: allCompletions;
|
||||
const win = bonusWindow(cfg, paydayEval, tzEval);
|
||||
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
||||
|
||||
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||
let createdReward = false;
|
||||
@@ -241,11 +238,10 @@ export async function progress(pb: any, famId: string) {
|
||||
|
||||
for (const cfg of configsData) {
|
||||
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||
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 >= pStart && c.date <= pEnd)
|
||||
: completionsList;
|
||||
const win = bonusWindow(cfg, payday, tz);
|
||||
const pStart = win.from;
|
||||
const pEnd = win.to;
|
||||
const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
|
||||
|
||||
const progressRows: any[] = [];
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ export interface BonusConfig {
|
||||
period?: BonusPeriod;
|
||||
status: BonusStatus;
|
||||
isPocketMoney?: boolean;
|
||||
completeBy?: 'unlimited' | 'week' | 'custom';
|
||||
startDate?: string;
|
||||
completeByDate?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
weekdayInTz,
|
||||
wallClockToUtc,
|
||||
resolveTz,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
isCompleteForPeriod
|
||||
isCompleteForPeriod,
|
||||
bonusWindow,
|
||||
completionInWindow
|
||||
} from '@shared/timezone';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -466,20 +466,20 @@
|
||||
return !cfg.memberId || cfg.memberId === memberId;
|
||||
})
|
||||
.map((cfg) => {
|
||||
const per = cfg.period || 'weekly';
|
||||
const pStart = periodStart(per, paydayDay, famTz);
|
||||
const pEnd = periodEnd(per, pStart);
|
||||
const win = bonusWindow(cfg, paydayDay, famTz);
|
||||
const pStart = win.from || '';
|
||||
const pEnd = win.to || '';
|
||||
const periodDays =
|
||||
Math.round(
|
||||
(new Date(pEnd + 'T00:00:00').getTime() - new Date(pStart + 'T00:00:00').getTime()) /
|
||||
86400000
|
||||
) + 1;
|
||||
pStart && pEnd
|
||||
? Math.round(
|
||||
(new Date(pEnd + 'T00:00:00').getTime() -
|
||||
new Date(pStart + 'T00:00:00').getTime()) /
|
||||
86400000
|
||||
) + 1
|
||||
: 1;
|
||||
|
||||
const periodCompletions = completions.filter(
|
||||
(c) =>
|
||||
c.memberId === memberId &&
|
||||
(c.date?.slice(0, 10) || c.date) >= pStart &&
|
||||
(c.date?.slice(0, 10) || c.date) <= pEnd
|
||||
(c) => c.memberId === memberId && completionInWindow(c, win)
|
||||
);
|
||||
|
||||
// Percent thresholds are "X% of the chores done this period" — the
|
||||
|
||||
@@ -119,6 +119,12 @@ export const actions = {
|
||||
if (description) data.description = description;
|
||||
const memberId = fd.get('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 {
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.create('bonus-configs', famId, data);
|
||||
@@ -157,6 +163,12 @@ export const actions = {
|
||||
if (description) data.description = description;
|
||||
const memberId = fd.get('memberId');
|
||||
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 {
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.update('bonus-configs', famId, id, data);
|
||||
@@ -199,7 +211,10 @@ export const actions = {
|
||||
period: fd.get('period') || '',
|
||||
memberId: fd.get('memberId') || '',
|
||||
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 = '';
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,23 @@
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
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 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 {
|
||||
BonusConfig,
|
||||
BonusTemplate,
|
||||
@@ -18,9 +33,24 @@
|
||||
|
||||
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 editingConfig = $state<BonusConfig | 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.
|
||||
@@ -56,7 +86,10 @@
|
||||
rewardValue: '100',
|
||||
criteriaValue: 10,
|
||||
period: '' as string,
|
||||
startMode: 'today' as string
|
||||
startMode: 'today' as string,
|
||||
completeBy: 'unlimited' as string,
|
||||
startDate: '' as string,
|
||||
completeByDate: '' as string
|
||||
});
|
||||
|
||||
let configs = $derived(
|
||||
@@ -79,8 +112,9 @@
|
||||
|
||||
let showPeriod = $derived(editVals.occurrence !== 'once');
|
||||
let showCriteria = $derived(editVals.type !== 'manual');
|
||||
let createIsPercent = $derived(createVals.type === 'threshold' && createVals.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 allBonusConfigs = $derived(
|
||||
@@ -157,8 +191,12 @@
|
||||
rewardValue: tpl.rewardValue,
|
||||
criteriaValue: tpl.criteriaValue || 10,
|
||||
period: tpl.occurrence === 'once' ? '' : tpl.period || '',
|
||||
startMode: 'today'
|
||||
startMode: 'today',
|
||||
completeBy: 'unlimited',
|
||||
startDate: todayIso(),
|
||||
completeByDate: ''
|
||||
};
|
||||
step = 1;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
@@ -205,8 +243,12 @@
|
||||
rewardValue: cfg.rewardValue,
|
||||
criteriaValue: cfg.criteriaValue || 10,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -496,9 +538,32 @@
|
||||
role="presentation"
|
||||
>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>
|
||||
<h3 class="modal-title">
|
||||
{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}
|
||||
</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
|
||||
method="POST"
|
||||
action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'}
|
||||
@@ -510,199 +575,309 @@
|
||||
{#if creatingFromTemplate}
|
||||
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} />
|
||||
{/if}
|
||||
<label>Name <input name="name" bind:value={editVals.name} required /></label>
|
||||
<label>Description <input name="description" bind:value={editVals.description} /></label
|
||||
>
|
||||
<label
|
||||
>Target
|
||||
<select name="target" bind:value={editVals.target}>
|
||||
<option value="individual">Individual</option>
|
||||
<option value="competitive">Competition</option>
|
||||
<option value="collaborative">Collaborative</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
|
||||
<label
|
||||
>Member
|
||||
<select name="memberId" bind:value={editVals.memberId}>
|
||||
<option value="">All (everyone)</option>
|
||||
{#each members as m}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<label
|
||||
>Type
|
||||
<select name="type" bind:value={editVals.type}>
|
||||
<option value="threshold">Threshold</option>
|
||||
<option value="count">Count</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if editVals.type === 'threshold'}
|
||||
<label
|
||||
>Threshold by
|
||||
<select name="thresholdType" bind:value={editVals.thresholdType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="percent">% of chores done</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if showCriteria}
|
||||
{#if editIsPercent}
|
||||
<label
|
||||
>Chores completed (%)
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
name="criteriaValue"
|
||||
bind:value={editVals.criteriaValue}
|
||||
/>
|
||||
<span class="hint">{editVals.criteriaValue}% of chores this week</span>
|
||||
|
||||
<!-- STEP 1 — Who (basics) -->
|
||||
{#if step === 1}
|
||||
<div class="step-body">
|
||||
<label>
|
||||
<span class="field-label">{@html stepIcon(1)} Name</span>
|
||||
<input name="name" bind:value={editVals.name} required />
|
||||
</label>
|
||||
{:else}
|
||||
<label
|
||||
>Target Value
|
||||
<input
|
||||
type="number"
|
||||
name="criteriaValue"
|
||||
bind:value={editVals.criteriaValue}
|
||||
required
|
||||
/>
|
||||
<span class="hint"
|
||||
>{editVals.type === 'threshold'
|
||||
? 'Points to reach'
|
||||
: editVals.type === 'count'
|
||||
? 'Number of chores'
|
||||
: 'N/A'}</span
|
||||
>
|
||||
<label>
|
||||
<span class="field-label">{@html stepIcon(1)} Description</span>
|
||||
<input name="description" bind:value={editVals.description} />
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
<label
|
||||
>Occurrence
|
||||
<select name="occurrence" bind:value={editVals.occurrence}>
|
||||
<option value="recurring">Recurring</option>
|
||||
<option value="once">Once</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if showPeriod}
|
||||
<label
|
||||
>Period
|
||||
<select name="period" bind:value={editVals.period}>
|
||||
<option value="">None</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<label
|
||||
>Reward Type
|
||||
<select name="rewardType" bind:value={editVals.rewardType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="prize">Prize</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>Reward Value
|
||||
<input name="rewardValue" bind:value={editVals.rewardValue} required />
|
||||
<span class="hint"
|
||||
>{editVals.rewardType === 'cash'
|
||||
? 'Amount in £'
|
||||
: editVals.rewardType === 'points'
|
||||
? 'Points to award'
|
||||
: 'Prize description'}</span
|
||||
>
|
||||
</label>
|
||||
{#if creatingFromTemplate}
|
||||
<div class="start-mode">
|
||||
<label>When to start</label>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<span class="field-label"><UsersRound size={14} /> Target (who)</span>
|
||||
<select name="target" bind:value={editVals.target}>
|
||||
<option value="individual">Individual</option>
|
||||
<option value="competitive">Competition</option>
|
||||
<option value="collaborative">Collaborative</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="startMode"
|
||||
value="disabled"
|
||||
bind:group={editVals.startMode}
|
||||
/>
|
||||
<span>Disabled</span>
|
||||
<span class="field-label"><UsersRound size={14} /> Member</span>
|
||||
<select name="memberId" bind:value={editVals.memberId}>
|
||||
<option value="">All (everyone)</option>
|
||||
{#each members as m}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="startMode"
|
||||
value="today"
|
||||
bind:group={editVals.startMode}
|
||||
/>
|
||||
<span>Today</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="startMode"
|
||||
value="nextWeek"
|
||||
bind:group={editVals.startMode}
|
||||
/>
|
||||
<span>Next week</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/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}>
|
||||
<option value="threshold">Threshold</option>
|
||||
<option value="count">Count</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if editVals.type === 'threshold'}
|
||||
<label>
|
||||
<span class="field-label"><Target size={14} /> Threshold by</span>
|
||||
<select name="thresholdType" bind:value={editVals.thresholdType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="percent">% of chores done</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if showCriteria}
|
||||
{#if editIsPercent}
|
||||
<label>
|
||||
<span class="field-label"><Target size={14} /> Chores completed (%)</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
name="criteriaValue"
|
||||
bind:value={editVals.criteriaValue}
|
||||
/>
|
||||
<span class="hint">{editVals.criteriaValue}% of chores this week</span>
|
||||
</label>
|
||||
{:else}
|
||||
<label>
|
||||
<span class="field-label"><Target size={14} /> Target Value</span>
|
||||
<input
|
||||
type="number"
|
||||
name="criteriaValue"
|
||||
bind:value={editVals.criteriaValue}
|
||||
required
|
||||
/>
|
||||
<span class="hint"
|
||||
>{editVals.type === 'threshold'
|
||||
? 'Points to reach'
|
||||
: editVals.type === 'count'
|
||||
? 'Number of chores'
|
||||
: 'N/A'}</span
|
||||
>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
<label>
|
||||
<span class="field-label"><Settings2 size={14} /> Occurrence</span>
|
||||
<select name="occurrence" bind:value={editVals.occurrence}>
|
||||
<option value="recurring">Recurring</option>
|
||||
<option value="once">Once</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if showPeriod}
|
||||
<label>
|
||||
<span class="field-label"><CalendarDays size={14} /> Period</span>
|
||||
<select name="period" bind:value={editVals.period}>
|
||||
<option value="">None</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
<span class="hint"
|
||||
>Recurring bonuses reset and re-earn each period. Leave as "None" for a
|
||||
one-off reward measured once.</span
|
||||
>
|
||||
</label>
|
||||
{/if}
|
||||
{#if showProgressWindow}
|
||||
<div class="start-mode">
|
||||
<label
|
||||
><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}>
|
||||
<option value="points">Points</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="prize">Prize</option>
|
||||
</select>
|
||||
</label>
|
||||
<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 Value
|
||||
</span>
|
||||
<input name="rewardValue" bind:value={editVals.rewardValue} required />
|
||||
<span class="hint"
|
||||
>{editVals.rewardType === 'cash'
|
||||
? 'Amount in £'
|
||||
: editVals.rewardType === 'points'
|
||||
? 'Points to award'
|
||||
: 'Prize description'}</span
|
||||
>
|
||||
</label>
|
||||
{#if creatingFromTemplate}
|
||||
<div class="start-mode">
|
||||
<label
|
||||
><span class="field-label"><CalendarDays size={14} /> When to start</span
|
||||
></label>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="startMode"
|
||||
value="disabled"
|
||||
bind:group={editVals.startMode}
|
||||
/>
|
||||
<span>Disabled</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="startMode"
|
||||
value="today"
|
||||
bind:group={editVals.startMode}
|
||||
onchange={() => (editVals.startDate = todayIso())}
|
||||
/>
|
||||
<span>Today</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="startMode"
|
||||
value="nextWeek"
|
||||
bind:group={editVals.startMode}
|
||||
onchange={() => (editVals.startDate = addDaysStr(todayIso(), 7))}
|
||||
/>
|
||||
<span>Next week</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="modal-actions">
|
||||
{#if editingConfig}
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-btn"
|
||||
onclick={async () => {
|
||||
if (!editingConfig) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', editingConfig.id);
|
||||
fd.append('currentStatus', editingConfig.status);
|
||||
const res = await fetch('?/toggleConfig', { method: 'POST', body: fd });
|
||||
if (res.ok) showEditModal = false;
|
||||
}}
|
||||
>
|
||||
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
|
||||
{#if step === 2}
|
||||
<button type="button" class="nav-btn" onclick={() => (step = 1)}>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
{#if editingConfig.status !== 'completed'}
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
class="complete-btn"
|
||||
class="toggle-btn"
|
||||
onclick={async () => {
|
||||
if (!editingConfig) return;
|
||||
if (!confirm('Mark this bonus as completed?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', editingConfig.id);
|
||||
const res = await fetch('?/completeConfig', { method: 'POST', body: fd });
|
||||
fd.append('currentStatus', editingConfig.status);
|
||||
const res = await fetch('?/toggleConfig', { method: 'POST', body: fd });
|
||||
if (res.ok) showEditModal = false;
|
||||
}}
|
||||
>
|
||||
Mark Complete
|
||||
<Ban size={14} />
|
||||
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
|
||||
</button>
|
||||
{#if editingConfig.status !== 'completed'}
|
||||
<button
|
||||
type="button"
|
||||
class="complete-btn"
|
||||
onclick={async () => {
|
||||
if (!editingConfig) return;
|
||||
if (!confirm('Mark this bonus as completed?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', editingConfig.id);
|
||||
const res = await fetch('?/completeConfig', { method: 'POST', body: fd });
|
||||
if (res.ok) showEditModal = false;
|
||||
}}
|
||||
>
|
||||
<Crown size={14} /> Complete
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="delete-btn"
|
||||
onclick={async () => {
|
||||
if (!editingConfig) return;
|
||||
if (!confirm('Delete this bonus?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', editingConfig.id);
|
||||
const res = await fetch('?/destroyConfig', { method: 'POST', body: fd });
|
||||
if (res.ok) {
|
||||
showEditModal = false;
|
||||
editingConfig = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="delete-btn"
|
||||
onclick={async () => {
|
||||
if (!editingConfig) return;
|
||||
if (!confirm('Delete this bonus?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', editingConfig.id);
|
||||
const res = await fetch('?/destroyConfig', { method: 'POST', body: fd });
|
||||
if (res.ok) {
|
||||
showEditModal = false;
|
||||
editingConfig = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
<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>
|
||||
{/if}
|
||||
<button type="submit">{creatingFromTemplate ? 'Assign' : 'Save'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1066,6 +1241,7 @@
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
@@ -1073,12 +1249,17 @@
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.modal-actions button.nav-btn,
|
||||
.modal-actions button[type='button'] {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.modal-actions button[type='submit'] {
|
||||
.modal-actions button.next-btn,
|
||||
.modal-actions button[type='submit'].save-btn {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
@@ -1094,6 +1275,52 @@
|
||||
background: #ef4444;
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@@ -1113,5 +1340,19 @@
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
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>
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
|
||||
<p class="alt">
|
||||
Don't have a family yet? <a href="/signup">Create one</a>
|
||||
</p>
|
||||
<p>Trying to join as a child? Ask your parent to issue your QR code.</p>
|
||||
</AuthShell>
|
||||
|
||||
<style>
|
||||
@@ -63,7 +63,9 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #3730a3; }
|
||||
button:hover {
|
||||
background: #3730a3;
|
||||
}
|
||||
.form-error {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
@@ -79,5 +81,9 @@
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
||||
.alt a {
|
||||
color: #4338ca;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -229,6 +229,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||
select("status", ["active", "completed"], true),
|
||||
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,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
|
||||
@@ -104,6 +104,43 @@ export function periodEnd(period: string, start: string): string {
|
||||
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
|
||||
// current period. Weekly chores are done once per week ([weekStart, +7)),
|
||||
// daily chores once per day ([today, +1)). Half-open [from, to).
|
||||
|
||||
Reference in New Issue
Block a user