fix bonus completeions

This commit is contained in:
JCEEE
2026-09-14 13:00:13 +01:00
parent e441c3d2a7
commit da6a6e372c
7 changed files with 60 additions and 12 deletions
+11
View File
@@ -589,6 +589,17 @@ async function ensureBonusFields(): Promise<void> {
fields.push({ name: 'targetChoreIds', type: 'json', required: false }); fields.push({ name: 'targetChoreIds', type: 'json', required: false });
changed = true; 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. // 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) {
+13 -1
View File
@@ -252,8 +252,20 @@ export async function evaluateFam(pb: any, famId: string) {
} }
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) { 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 { 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 {} } catch {}
} }
} }
+5 -1
View File
@@ -79,7 +79,11 @@ export function createServices(
return pb.collection('bonus_configs').update(configId, updates); return pb.collection('bonus_configs').update(configId, updates);
}, },
complete: (famId: string, configId: string) => 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) destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId)
}, },
settings: { settings: {
+26 -2
View File
@@ -1,10 +1,31 @@
import { todayInTz, resolveTz } from '@shared/timezone'; import { todayInTz, resolveTz } from '@shared/timezone';
import { famMeta } from './fam'; import { famMeta } from './fam';
import { pbAdmin } from '$lib/server/pocketbase';
function resolveServerTz(tz?: string): string { function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto'); 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) { export function assertPaydayUnlocked(reward: any, tz?: string) {
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
const today = todayInTz(resolveServerTz(tz)); const today = todayInTz(resolveServerTz(tz));
@@ -28,10 +49,12 @@ export async function claim(pb: any, famId: string, id: string) {
// Admin approval → status 'claimed'. // Admin approval → status 'claimed'.
export async function approve(pb: any, famId: string, id: string) { 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', status: 'claimed',
claimedAt: new Date().toISOString() claimedAt: new Date().toISOString()
}); });
await completeOnceConfig(famId, record);
return record;
} }
export async function requestAll(pb: any, famId: string, memberId: string) { 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; let count = 0;
for (const r of rewards) { 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++; count++;
} }
return { count }; return { count };
+2
View File
@@ -146,6 +146,8 @@ export interface BonusConfig {
criteriaValue?: number; criteriaValue?: number;
period?: BonusPeriod; period?: BonusPeriod;
status: BonusStatus; status: BonusStatus;
completedBy?: string;
completedAt?: string;
targetChoreId?: string; targetChoreId?: string;
targetChoreIds?: Record<string, string>; targetChoreIds?: Record<string, string>;
isPocketMoney?: boolean; isPocketMoney?: boolean;
@@ -623,12 +623,6 @@
const rDate = (existingReward?.date || '').slice(0, 10); const rDate = (existingReward?.date || '').slice(0, 10);
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd; const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed'; 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 const achieved = existingReward
? rewardInWeek || rewardOutstanding ? rewardInWeek || rewardOutstanding
: isPercent : isPercent
@@ -659,8 +653,7 @@
periodStart: pStart, periodStart: pStart,
periodEnd: pEnd periodEnd: pEnd
}; };
}) });
.filter((g) => g !== null);
}); });
// ─── Goal celebrations (bonus rewards achieved) ─── // ─── Goal celebrations (bonus rewards achieved) ───
+2
View File
@@ -228,6 +228,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
rel("memberId", ids.users), rel("memberId", ids.users),
select("period", ["schedule", "daily", "weekly", "monthly"]), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true), select("status", ["active", "completed"], true),
text("completedBy"),
date("completedAt"),
text("targetChoreId"), text("targetChoreId"),
jsonField("targetChoreIds"), jsonField("targetChoreIds"),
bool("isPocketMoney"), bool("isPocketMoney"),