added pocketmoney default droplet
This commit is contained in:
@@ -55,6 +55,35 @@ export async function createChild(opts: {
|
||||
}
|
||||
|
||||
if (!user) throw new Error('Failed to create child');
|
||||
|
||||
// Auto-create the child's pocket-money droplet. The amount (rewardValue)
|
||||
// starts empty — the parent sets it on the Bonuses page. This keeps pocket
|
||||
// money a first-class, always-present feature without a separate field.
|
||||
const pm = await pb
|
||||
.collection('bonus_configs')
|
||||
.getFirstListItem(`famId='${opts.famId}' && memberId='${user.id}' && isPocketMoney=true`)
|
||||
.catch(() => null);
|
||||
if (!pm) {
|
||||
await pb
|
||||
.collection('bonus_configs')
|
||||
.create({
|
||||
famId: opts.famId,
|
||||
name: 'Pocket Money',
|
||||
target: 'individual',
|
||||
type: 'threshold',
|
||||
thresholdType: 'percent',
|
||||
occurrence: 'recurring',
|
||||
rewardType: 'cash',
|
||||
rewardValue: '',
|
||||
criteriaValue: 50,
|
||||
memberId: user.id,
|
||||
period: 'weekly',
|
||||
status: 'active',
|
||||
isPocketMoney: true
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +327,76 @@ export async function migrate(): Promise<void> {
|
||||
// Runs even when the schema already exists (unlike ensureSchema's early
|
||||
// return) so new platform collections/fields/seed land on existing installs.
|
||||
await ensureFamFields();
|
||||
await ensureBonusFields();
|
||||
await ensureAccessCodes();
|
||||
await ensurePlatform();
|
||||
console.log("[migrate] Done");
|
||||
}
|
||||
|
||||
// Add the pocket-money fields to bonus collections on existing installs, and
|
||||
// backfill a default pocket-money droplet (£10 / 50% chores) for any child that
|
||||
// doesn't yet have one. Idempotent.
|
||||
async function ensureBonusFields(): Promise<void> {
|
||||
for (const name of ["bonus_templates", "bonus_configs"]) {
|
||||
const col = await getCollection(name);
|
||||
if (!col) continue;
|
||||
const has = (n: string) => col.fields.some((f: any) => f.name === n);
|
||||
let changed = false;
|
||||
const fields = [...col.fields];
|
||||
if (!has("thresholdType")) {
|
||||
fields.push({
|
||||
name: "thresholdType", type: "select", required: false,
|
||||
values: ["points", "percent"], maxSelect: 1,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if (!has("isPocketMoney")) {
|
||||
fields.push({ name: "isPocketMoney", type: "bool", 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) {
|
||||
rv.required = false;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) await updateCollection(col.id, { ...col, fields });
|
||||
}
|
||||
await backfillPocketMoney();
|
||||
}
|
||||
|
||||
async function backfillPocketMoney(): Promise<void> {
|
||||
const t = await auth();
|
||||
const childrenRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
const children: any[] = (await childrenRes.json())?.items || [];
|
||||
for (const c of children) {
|
||||
const existingRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=famId='${c.famId}' && memberId='${c.id}' && isPocketMoney=true`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
if ((await existingRes.json())?.items?.length) continue;
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
famId: c.famId,
|
||||
name: "Pocket Money",
|
||||
target: "individual",
|
||||
type: "threshold",
|
||||
thresholdType: "percent",
|
||||
occurrence: "recurring",
|
||||
rewardType: "cash",
|
||||
rewardValue: 10,
|
||||
criteriaValue: 50,
|
||||
memberId: c.id,
|
||||
period: "weekly",
|
||||
status: "active",
|
||||
isPocketMoney: true,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
console.log("[migrate] Pocket-money droplets ensured.");
|
||||
}
|
||||
@@ -44,7 +44,17 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
} catch {}
|
||||
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId);
|
||||
|
||||
// Chores "due" this week per member (daily = 7, otherwise 1). Used by the
|
||||
// `percent` threshold type to compute % of chores completed.
|
||||
const dueByMember: Record<string, number> = {};
|
||||
for (const a of allAssigned) {
|
||||
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
||||
}
|
||||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
||||
|
||||
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
|
||||
@@ -82,10 +92,15 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||
let current = 0;
|
||||
if (cfg.type === 'threshold') {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
if (cfg.thresholdType === 'percent') {
|
||||
const due = dueByMember[m.id] || 0;
|
||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||
} else {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
current = memberCompletions.length;
|
||||
}
|
||||
@@ -114,10 +129,14 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
||||
let total = 0;
|
||||
if (cfg.type === 'threshold') {
|
||||
total = teamCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
if (cfg.thresholdType === 'percent') {
|
||||
total = Math.round((teamCompletions.length / totalDue) * 100);
|
||||
} else {
|
||||
total = teamCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
total = teamCompletions.length;
|
||||
}
|
||||
@@ -145,10 +164,15 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||
let current = 0;
|
||||
if (cfg.type === 'threshold') {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
if (cfg.thresholdType === 'percent') {
|
||||
const due = dueByMember[m.id] || 0;
|
||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||
} else {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
current = memberCompletions.length;
|
||||
}
|
||||
@@ -203,6 +227,11 @@ export async function progress(pb: any, famId: string) {
|
||||
|
||||
const assignedList = assigned;
|
||||
const completionsList = completions;
|
||||
const dueByMember: Record<string, number> = {};
|
||||
for (const a of assignedList) {
|
||||
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
||||
}
|
||||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
||||
let allRewards: any[] = [];
|
||||
try {
|
||||
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||
@@ -226,10 +255,14 @@ export async function progress(pb: any, famId: string) {
|
||||
);
|
||||
let teamCurrent = 0;
|
||||
if (cfg.type === 'threshold') {
|
||||
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
if (cfg.thresholdType === 'percent') {
|
||||
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
|
||||
} else {
|
||||
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
teamCurrent = teamCompletions.length;
|
||||
}
|
||||
@@ -257,10 +290,15 @@ export async function progress(pb: any, famId: string) {
|
||||
|
||||
let current = 0;
|
||||
if (cfg.type === 'threshold') {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
if (cfg.thresholdType === 'percent') {
|
||||
const due = dueByMember[m.id] || 0;
|
||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||
} else {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
current = memberCompletions.length;
|
||||
} else if (cfg.type === 'manual') {
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Frequency = 'daily' | 'weekly';
|
||||
export type RewardType = 'points' | 'money' | 'emoji';
|
||||
export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
|
||||
export type BonusType = 'threshold' | 'count' | 'manual';
|
||||
export type BonusThresholdType = 'points' | 'percent';
|
||||
export type BonusOccurrence = 'recurring' | 'once';
|
||||
export type BonusRewardType = 'points' | 'cash' | 'prize';
|
||||
export type BonusPeriod = 'weekly' | 'monthly' | 'daily';
|
||||
@@ -15,11 +16,13 @@ export interface BonusTemplate {
|
||||
description?: string;
|
||||
target: BonusTarget;
|
||||
type: BonusType;
|
||||
thresholdType?: BonusThresholdType;
|
||||
occurrence: BonusOccurrence;
|
||||
rewardType: BonusRewardType;
|
||||
rewardValue: string;
|
||||
criteriaValue?: number;
|
||||
period?: BonusPeriod;
|
||||
isPocketMoney?: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
@@ -117,12 +120,14 @@ export interface BonusConfig {
|
||||
target: BonusTarget;
|
||||
memberId?: string;
|
||||
type: BonusType;
|
||||
thresholdType?: BonusThresholdType;
|
||||
occurrence: BonusOccurrence;
|
||||
rewardType: BonusRewardType;
|
||||
rewardValue: string;
|
||||
criteriaValue?: number;
|
||||
period?: BonusPeriod;
|
||||
status: BonusStatus;
|
||||
isPocketMoney?: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user