From 2abc9788a230a3f4943f633e688fb7531e8e661e Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 7 Sep 2026 20:53:37 +0100 Subject: [PATCH] dynamic chore counts --- frontend/src/lib/server/migrate.ts | 19 +++- frontend/src/lib/server/services/bonuses.ts | 45 ++++---- frontend/src/lib/types.ts | 1 + .../[fam]/[username]/rewards/+page.server.ts | 27 +++++ .../[fam]/[username]/rewards/+page.svelte | 100 ++++++++++++++++-- shared/pb/schema.ts | 1 + 6 files changed, 155 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index ee4b2df..f185920 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -438,6 +438,7 @@ async function ensureAssignedChoreFields(): Promise { const col = await getCollection('assigned_chores'); if (!col) return; const has = (n: string) => col.fields.some((f: any) => f.name === n); + const fields = [...col.fields]; const needed: any[] = []; if (!has('description')) needed.push({ name: 'description', type: 'text', required: false }); if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false }); @@ -445,27 +446,31 @@ async function ensureAssignedChoreFields(): Promise { if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false }); // Shared chores: memberId becomes optional, shared boolean tracks original mode if (!has('shared')) needed.push({ name: 'shared', type: 'bool', required: false }); + let changed = false; + if (needed.length) { + fields.push(...needed); + changed = true; + } // Todos can be celebration-only (emoji) as well as points/money. - let changed = !!needed.length; - const typeField = col.fields.find((f: any) => f.name === 'type'); + const typeField = fields.find((f: any) => f.name === 'type'); if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) { typeField.values = [...typeField.values, 'emoji']; changed = true; } // Direct todos (created by a parent, not from a template) have no templateId. - const tplField = col.fields.find((f: any) => f.name === 'templateId'); + const tplField = fields.find((f: any) => f.name === 'templateId'); if (tplField && tplField.required) { tplField.required = false; changed = true; } // memberId must be optional for shared chores (null = shared with all members) - const memberField = col.fields.find((f: any) => f.name === 'memberId'); + const memberField = fields.find((f: any) => f.name === 'memberId'); if (memberField && memberField.required) { memberField.required = false; changed = true; } if (changed) { - await updateCollection(col.id, { ...col, fields: [...col.fields] }); + await updateCollection(col.id, { ...col, fields }); } const comp = await getCollection('completions'); if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) { @@ -526,6 +531,10 @@ async function ensureBonusFields(): Promise { fields.push({ name: 'targetChoreId', type: 'text', required: false }); changed = true; } + if (name === 'bonus_configs' && !has('targetChoreIds')) { + fields.push({ name: 'targetChoreIds', type: 'json', 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 c9eecc5..027365f 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -24,6 +24,13 @@ function claimableStamp(cfg: any, payday: number, tz: string) { return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) }; } +function targetChoreFor(cfg: any, memberId: string): string | undefined { + if (cfg.targetChoreIds && typeof cfg.targetChoreIds === 'object' && cfg.targetChoreIds[memberId]) { + return cfg.targetChoreIds[memberId]; + } + return cfg.targetChoreId || undefined; +} + export async function evaluateFam(pb: any, famId: string) { let configs: any[] = []; try { @@ -58,15 +65,7 @@ export async function evaluateFam(pb: any, famId: string) { // Pocket money pauses until the parent sets an amount. if (cfg.isPocketMoney && !cfg.rewardValue) continue; const win = bonusWindow(cfg, paydayEval, tzEval); - let periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); - - // Count-type rewards can pin against a single assigned chore (targetChoreId). - // When set, only completions of that specific chore count toward the target. - if (cfg.type === 'count' && cfg.targetChoreId) { - periodCompletions = periodCompletions.filter( - (c: any) => c.assignedChoreId === cfg.targetChoreId - ); - } + const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); let createdReward = false; @@ -94,7 +93,9 @@ export async function evaluateFam(pb: any, famId: string) { if (cfg.target === 'individual') { const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers; for (const m of targetMembers) { - const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + const memberTarget = targetChoreFor(cfg, m.id); + let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget); let current = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -131,7 +132,8 @@ export async function evaluateFam(pb: any, famId: string) { } } else if (cfg.target === 'collaborative') { const allMemberIds = allMembers.map((m: any) => m.id); - const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); + let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); + if (cfg.targetChoreId) teamCompletions = teamCompletions.filter((c: any) => c.assignedChoreId === cfg.targetChoreId); let total = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -166,7 +168,9 @@ export async function evaluateFam(pb: any, famId: string) { } } else if (cfg.target === 'competitive') { const scored = allMembers.map((m: any) => { - const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + const memberTarget = targetChoreFor(cfg, m.id); + let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget); let current = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -249,22 +253,15 @@ export async function progress(pb: any, famId: string) { const win = bonusWindow(cfg, payday, tz); const pStart = win.from; const pEnd = win.to; - let periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win)); - - // Count-type rewards pinned to a specific chore only count that chore's - // completions toward the target. - if (cfg.type === 'count' && cfg.targetChoreId) { - periodCompletions = periodCompletions.filter( - (c: any) => c.assignedChoreId === cfg.targetChoreId - ); - } + const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win)); const progressRows: any[] = []; if (cfg.target === 'collaborative') { - const teamCompletions = periodCompletions.filter((c: any) => + let teamCompletions = periodCompletions.filter((c: any) => members.some((m: any) => m.id === c.memberId) ); + if (cfg.targetChoreId) teamCompletions = teamCompletions.filter((c: any) => c.assignedChoreId === cfg.targetChoreId); let teamCurrent = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -297,7 +294,9 @@ export async function progress(pb: any, famId: string) { ? members.filter((m: any) => m.id === cfg.memberId) : members; for (const m of progressMembers) { - const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + const memberTarget = targetChoreFor(cfg, m.id); + let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget); const memberReward = cfgRewards.find((r: any) => r.memberId === m.id); let current = 0; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e38aa1f..39cea8c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -139,6 +139,7 @@ export interface BonusConfig { period?: BonusPeriod; status: BonusStatus; targetChoreId?: string; + targetChoreIds?: Record; isPocketMoney?: boolean; completeBy?: 'unlimited' | 'week' | 'custom'; startDate?: string; diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts index 0de8228..66df2eb 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts @@ -1,6 +1,24 @@ import { fail, redirect } from '@sveltejs/kit'; import { servicesFor } from '$lib/server/servicesFor'; +// Per-member target-chore picks come in as `targetChore_` form fields. +// Build the memberId → assignedChoreId map (empty values = count all of that +// member's chores, so they're dropped). `used` tells callers whether the +// per-member select UI was rendered at all (i.e. an individual-target reward), +// so the old scalar targetChoreId can be cleared on save. +function parseTargetChoreIds(fd: FormData): { map: Record; used: boolean } { + const map: Record = {}; + let used = false; + for (const [k, v] of fd.entries()) { + if (k.startsWith('targetChore_')) { + used = true; + const memberId = k.slice('targetChore_'.length); + if (v) map[memberId] = String(v); + } + } + return { map, used }; +} + export async function load(event) { if (!event.locals.user) throw redirect(303, '/login'); const famId = event.locals.user.famId; @@ -117,6 +135,9 @@ export const actions = { if (data.occurrence === 'once') data.period = ''; const targetChoreId = fd.get('targetChoreId'); if (targetChoreId !== null) data.targetChoreId = targetChoreId || ''; + const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd); + if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds; + if (perMemberTargets) data.targetChoreId = ''; const description = fd.get('description'); if (description) data.description = description; const memberId = fd.get('memberId'); @@ -163,6 +184,9 @@ export const actions = { if (occurrence === 'once') data.period = ''; const targetChoreId = fd.get('targetChoreId'); if (targetChoreId !== null) data.targetChoreId = targetChoreId || ''; + const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd); + if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds; + if (perMemberTargets) data.targetChoreId = ''; const description = fd.get('description'); if (description) data.description = description; const memberId = fd.get('memberId'); @@ -221,6 +245,9 @@ export const actions = { startDate: fd.get('startDate') || '', completeByDate: fd.get('completeByDate') || '' }; + const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd); + if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds; + if (perMemberTargets) data.targetChoreId = ''; if (data.occurrence === 'once') data.period = ''; try { const s = servicesFor(event); diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte index a45fe4a..6eff587 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -26,6 +26,7 @@ BonusConfig, BonusTemplate, Member, + AssignedChore, BonusConfigWithProgress, BonusProgress, Reward @@ -90,7 +91,8 @@ completeBy: 'unlimited' as string, startDate: '' as string, completeByDate: '' as string, - targetChoreId: '' as string + targetChoreId: '' as string, + targetChoreIds: {} as Record }); let configs = $derived( @@ -125,6 +127,25 @@ ) ); + // Chores assigned to a particular member (plus shared chores, which every + // member can complete). Used for the dynamic per-member target-chore + // dropdowns when a count:chores reward is set to target "All (everyone)". + function choresForMember(memberId: string): AssignedChore[] { + return famStore.assigned.filter((a) => !a.isTodo && (!a.memberId || a.memberId === memberId)); + } + + function choreName(ac: AssignedChore): string { + return ac.customName || famStore.templateMap().get(ac.templateId || '')?.name || 'Chore'; + } + + function hasTargetChore(cfg: BonusConfig): boolean { + return !!cfg.targetChoreId || Object.values(cfg.targetChoreIds || {}).some(Boolean); + } + + function editTargetChore(memberId: string, value: string) { + editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value }; + } + let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let allBonusConfigs = $derived( configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] @@ -204,7 +225,8 @@ completeBy: 'unlimited', startDate: todayIso(), completeByDate: '', - targetChoreId: '' + targetChoreId: '', + targetChoreIds: {} }; step = 1; showEditModal = true; @@ -241,6 +263,12 @@ function openEditConfig(cfg: BonusConfig) { editingConfig = cfg; creatingFromTemplate = null; + const targetIds = (cfg.targetChoreIds as Record | undefined) || {}; + // Legacy individual rewards pinned a chore via the scalar targetChoreId — + // surface that in the per-member dropdown too. + if (cfg.target === 'individual' && cfg.memberId && cfg.targetChoreId && !targetIds[cfg.memberId]) { + targetIds[cfg.memberId] = cfg.targetChoreId; + } editVals = { name: cfg.name, description: cfg.description || '', @@ -257,7 +285,8 @@ completeBy: cfg.completeBy || 'unlimited', startDate: cfg.startDate || todayIso(), completeByDate: cfg.completeByDate || '', - targetChoreId: cfg.targetChoreId || '' + targetChoreId: cfg.targetChoreId || '', + targetChoreIds: targetIds }; step = 1; showEditModal = true; @@ -389,7 +418,7 @@ {/if} {#if cfg.type === 'count'} - {Math.min(current, criteria)} / {criteria}{cfg.targetChoreId ? ' chores' : ''} + {Math.min(current, criteria)} / {criteria}{hasTargetChore(cfg) ? ' chores' : ''} {/if} @@ -682,23 +711,74 @@ {/if} {/if} - {#if editVals.type === 'count'} +{#if editVals.type === 'count'} + {#if editVals.target === 'individual'} + {#if editVals.memberId} + {@const m = members.find((x) => x.id === editVals.memberId)} + {@const chores = choresForMember(editVals.memberId)} + + {:else} +

+ Pick a target chore per member — each child counts their own assigned + chores. +

+ {#each members as m} + {@const chores = choresForMember(m.id)} + + {/each} + {#if members.length === 0} +

No members yet

+ {/if} + {/if} + {:else}
{/if} + {/if} {#if !isPocketMoney}