diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index a5305ea..d6e6b89 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -589,6 +589,17 @@ async function ensureBonusFields(): Promise { fields.push({ name: 'targetChoreIds', type: 'json', required: false }); changed = true; } + // Completion audit (bonus_configs only): who/when a goal was finished — + // stamped by the manual "Mark as complete" action or automatically when + // a `once` goal's reward is approved. + if (name === 'bonus_configs' && !has('completedBy')) { + fields.push({ name: 'completedBy', type: 'text', required: false }); + changed = true; + } + if (name === 'bonus_configs' && !has('completedAt')) { + fields.push({ name: 'completedAt', type: 'date', 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) { diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 876f964..3b3a19e 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -252,8 +252,20 @@ export async function evaluateFam(pb: any, famId: string) { } if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) { + // Status write via superuser: evaluate often runs as a child token + // (fire-and-forget after a chore toggle) and bonus_configs updates + // are parent-only — the user-token write 403s and the config stays + // `active` forever. Stamp who earned it + when. + const earner = + existingRewards.find((r: any) => r.memberId)?.memberId || + allMembers[0]?.id || + 'auto'; try { - await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' }); + await pbAdmin.update('bonus_configs', cfg.id, { + status: 'completed', + completedAt: new Date().toISOString().slice(0, 10), + completedBy: earner + }); } catch {} } } diff --git a/frontend/src/lib/server/services/index.ts b/frontend/src/lib/server/services/index.ts index 299f831..2596963 100644 --- a/frontend/src/lib/server/services/index.ts +++ b/frontend/src/lib/server/services/index.ts @@ -79,7 +79,11 @@ export function createServices( return pb.collection('bonus_configs').update(configId, updates); }, complete: (famId: string, configId: string) => - pb.collection('bonus_configs').update(configId, { status: 'completed' }), + pb.collection('bonus_configs').update(configId, { + status: 'completed', + completedAt: new Date().toISOString().slice(0, 10), + completedBy: 'admin' + }), destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId) }, settings: { diff --git a/frontend/src/lib/server/services/rewards.ts b/frontend/src/lib/server/services/rewards.ts index a78be3e..d13ce19 100644 --- a/frontend/src/lib/server/services/rewards.ts +++ b/frontend/src/lib/server/services/rewards.ts @@ -1,10 +1,31 @@ import { todayInTz, resolveTz } from '@shared/timezone'; import { famMeta } from './fam'; +import { pbAdmin } from '$lib/server/pocketbase'; function resolveServerTz(tz?: string): string { return resolveTz(tz || 'auto'); } +// A `once` goal is finished the moment its reward is approved — complete the +// config here (not just in evaluateFam, which only runs on chore toggles) so +// the child dashboard stops offering it even before the next toggle. +// Config write via superuser: bonus_configs updates are parent-only. +// Outstanding (unclaimed/requested) rewards are untouched — they stay visible +// and collectible; only the approval moment completes the goal. +async function completeOnceConfig(famId: string, reward: any) { + if (!reward?.bonusConfigId) return; + try { + const cfg: any = await pbAdmin.getOne('bonus_configs', reward.bonusConfigId); + if (!cfg || cfg.famId !== famId) return; + if (cfg.occurrence !== 'once' || cfg.status === 'completed') return; + await pbAdmin.update('bonus_configs', cfg.id, { + status: 'completed', + completedAt: new Date().toISOString().slice(0, 10), + completedBy: reward.memberId || 'admin' + }); + } catch {} +} + export function assertPaydayUnlocked(reward: any, tz?: string) { if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; const today = todayInTz(resolveServerTz(tz)); @@ -28,10 +49,12 @@ export async function claim(pb: any, famId: string, id: string) { // Admin approval → status 'claimed'. export async function approve(pb: any, famId: string, id: string) { - return pb.collection('rewards').update(id, { + const record = await pb.collection('rewards').update(id, { status: 'claimed', claimedAt: new Date().toISOString() }); + await completeOnceConfig(famId, record); + return record; } export async function requestAll(pb: any, famId: string, memberId: string) { @@ -61,7 +84,8 @@ export async function issueAll(pb: any, famId: string, memberId: string) { }); let count = 0; for (const r of rewards) { - await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now }); + const record = await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now }); + await completeOnceConfig(famId, record); count++; } return { count }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e408e26..a195975 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -146,6 +146,8 @@ export interface BonusConfig { criteriaValue?: number; period?: BonusPeriod; status: BonusStatus; + completedBy?: string; + completedAt?: string; targetChoreId?: string; targetChoreIds?: Record; isPocketMoney?: boolean; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 7ecd69c..f37ad7c 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -623,12 +623,6 @@ const rDate = (existingReward?.date || '').slice(0, 10); const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd; const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed'; - // A claimed reward from a previous week with no fresh progress - // means the goal is done — drop the card entirely instead of - // showing a stale 0% chase. - const staleClaimed = - !!existingReward && !rewardInWeek && !rewardOutstanding && current === 0; - if (staleClaimed) return null; const achieved = existingReward ? rewardInWeek || rewardOutstanding : isPercent @@ -659,8 +653,7 @@ periodStart: pStart, periodEnd: pEnd }; - }) - .filter((g) => g !== null); + }); }); // ─── Goal celebrations (bonus rewards achieved) ─── diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index 84ee455..736cb0c 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -228,6 +228,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ rel("memberId", ids.users), select("period", ["schedule", "daily", "weekly", "monthly"]), select("status", ["active", "completed"], true), + text("completedBy"), + date("completedAt"), text("targetChoreId"), jsonField("targetChoreIds"), bool("isPocketMoney"),