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');
|
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;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,76 @@ export async function migrate(): Promise<void> {
|
|||||||
// Runs even when the schema already exists (unlike ensureSchema's early
|
// Runs even when the schema already exists (unlike ensureSchema's early
|
||||||
// return) so new platform collections/fields/seed land on existing installs.
|
// return) so new platform collections/fields/seed land on existing installs.
|
||||||
await ensureFamFields();
|
await ensureFamFields();
|
||||||
|
await ensureBonusFields();
|
||||||
await ensureAccessCodes();
|
await ensureAccessCodes();
|
||||||
await ensurePlatform();
|
await ensurePlatform();
|
||||||
console.log("[migrate] Done");
|
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 {}
|
} catch {}
|
||||||
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId);
|
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) {
|
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 pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : '';
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
|
||||||
const periodCompletions = cfg.period
|
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);
|
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
const due = dueByMember[m.id] || 0;
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||||
}, 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') {
|
} else if (cfg.type === 'count') {
|
||||||
current = memberCompletions.length;
|
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));
|
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
||||||
let total = 0;
|
let total = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
total = teamCompletions.reduce((sum: number, c: any) => {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
total = Math.round((teamCompletions.length / totalDue) * 100);
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
} else {
|
||||||
}, 0);
|
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') {
|
} else if (cfg.type === 'count') {
|
||||||
total = teamCompletions.length;
|
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);
|
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
const due = dueByMember[m.id] || 0;
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||||
}, 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') {
|
} else if (cfg.type === 'count') {
|
||||||
current = memberCompletions.length;
|
current = memberCompletions.length;
|
||||||
}
|
}
|
||||||
@@ -203,6 +227,11 @@ export async function progress(pb: any, famId: string) {
|
|||||||
|
|
||||||
const assignedList = assigned;
|
const assignedList = assigned;
|
||||||
const completionsList = completions;
|
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[] = [];
|
let allRewards: any[] = [];
|
||||||
try {
|
try {
|
||||||
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||||
@@ -226,10 +255,14 @@ export async function progress(pb: any, famId: string) {
|
|||||||
);
|
);
|
||||||
let teamCurrent = 0;
|
let teamCurrent = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
} else {
|
||||||
}, 0);
|
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') {
|
} else if (cfg.type === 'count') {
|
||||||
teamCurrent = teamCompletions.length;
|
teamCurrent = teamCompletions.length;
|
||||||
}
|
}
|
||||||
@@ -257,10 +290,15 @@ export async function progress(pb: any, famId: string) {
|
|||||||
|
|
||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
const due = dueByMember[m.id] || 0;
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||||
}, 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') {
|
} else if (cfg.type === 'count') {
|
||||||
current = memberCompletions.length;
|
current = memberCompletions.length;
|
||||||
} else if (cfg.type === 'manual') {
|
} else if (cfg.type === 'manual') {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type Frequency = 'daily' | 'weekly';
|
|||||||
export type RewardType = 'points' | 'money' | 'emoji';
|
export type RewardType = 'points' | 'money' | 'emoji';
|
||||||
export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
|
export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
|
||||||
export type BonusType = 'threshold' | 'count' | 'manual';
|
export type BonusType = 'threshold' | 'count' | 'manual';
|
||||||
|
export type BonusThresholdType = 'points' | 'percent';
|
||||||
export type BonusOccurrence = 'recurring' | 'once';
|
export type BonusOccurrence = 'recurring' | 'once';
|
||||||
export type BonusRewardType = 'points' | 'cash' | 'prize';
|
export type BonusRewardType = 'points' | 'cash' | 'prize';
|
||||||
export type BonusPeriod = 'weekly' | 'monthly' | 'daily';
|
export type BonusPeriod = 'weekly' | 'monthly' | 'daily';
|
||||||
@@ -15,11 +16,13 @@ export interface BonusTemplate {
|
|||||||
description?: string;
|
description?: string;
|
||||||
target: BonusTarget;
|
target: BonusTarget;
|
||||||
type: BonusType;
|
type: BonusType;
|
||||||
|
thresholdType?: BonusThresholdType;
|
||||||
occurrence: BonusOccurrence;
|
occurrence: BonusOccurrence;
|
||||||
rewardType: BonusRewardType;
|
rewardType: BonusRewardType;
|
||||||
rewardValue: string;
|
rewardValue: string;
|
||||||
criteriaValue?: number;
|
criteriaValue?: number;
|
||||||
period?: BonusPeriod;
|
period?: BonusPeriod;
|
||||||
|
isPocketMoney?: boolean;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
@@ -117,12 +120,14 @@ export interface BonusConfig {
|
|||||||
target: BonusTarget;
|
target: BonusTarget;
|
||||||
memberId?: string;
|
memberId?: string;
|
||||||
type: BonusType;
|
type: BonusType;
|
||||||
|
thresholdType?: BonusThresholdType;
|
||||||
occurrence: BonusOccurrence;
|
occurrence: BonusOccurrence;
|
||||||
rewardType: BonusRewardType;
|
rewardType: BonusRewardType;
|
||||||
rewardValue: string;
|
rewardValue: string;
|
||||||
criteriaValue?: number;
|
criteriaValue?: number;
|
||||||
period?: BonusPeriod;
|
period?: BonusPeriod;
|
||||||
status: BonusStatus;
|
status: BonusStatus;
|
||||||
|
isPocketMoney?: boolean;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -633,16 +633,33 @@
|
|||||||
{@const done = todays.length}
|
{@const done = todays.length}
|
||||||
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
|
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
|
||||||
{@const s = memberInSummary(m.id)}
|
{@const s = memberInSummary(m.id)}
|
||||||
|
{@const pm = parentBonusConfigs.find(
|
||||||
|
(c: any) => c.isPocketMoney && c.memberId === m.id
|
||||||
|
)}
|
||||||
<div class="member-card">
|
<div class="member-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="dot" style="background:{m.color}"></span>
|
<span class="dot" style="background:{m.color}"></span>
|
||||||
<span class="member-name">{m.name}</span>
|
<span class="member-name">{m.name}</span>
|
||||||
<a href="/{famSlug}/{handleOf(m.username)}" class="link">Kanban</a>
|
<Button
|
||||||
|
href="/{famSlug}/{username}/bonuses?s=pocketmoney"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm">Pocket money</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||||||
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pocket-row">
|
||||||
|
{#if pm && pm.rewardValue}
|
||||||
|
<span
|
||||||
|
>Pocket money: £{Number(pm.rewardValue).toFixed(2)} · {pm.criteriaValue || 0}% of
|
||||||
|
chores</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="unset">Pocket money: amount not set</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="progress-row">
|
<div class="progress-row">
|
||||||
<span class="label">Today:</span>
|
<span class="label">Today:</span>
|
||||||
<div class="bar-wrap">
|
<div class="bar-wrap">
|
||||||
@@ -1362,6 +1379,15 @@ const res = await fetch('/api/members', {
|
|||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.pocket-row {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
.pocket-row .unset {
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.stats {
|
.stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const actions = {
|
|||||||
description: fd.get('description') || '',
|
description: fd.get('description') || '',
|
||||||
target: fd.get('target'),
|
target: fd.get('target'),
|
||||||
type: fd.get('type'),
|
type: fd.get('type'),
|
||||||
|
thresholdType: fd.get('thresholdType') || 'points',
|
||||||
occurrence: fd.get('occurrence'),
|
occurrence: fd.get('occurrence'),
|
||||||
rewardType: fd.get('rewardType'),
|
rewardType: fd.get('rewardType'),
|
||||||
rewardValue: fd.get('rewardValue')
|
rewardValue: fd.get('rewardValue')
|
||||||
@@ -55,6 +56,8 @@ export const actions = {
|
|||||||
if (target) data.target = target;
|
if (target) data.target = target;
|
||||||
const type = fd.get('type');
|
const type = fd.get('type');
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
|
const thresholdType = fd.get('thresholdType');
|
||||||
|
if (thresholdType) data.thresholdType = thresholdType;
|
||||||
const occurrence = fd.get('occurrence');
|
const occurrence = fd.get('occurrence');
|
||||||
if (occurrence) data.occurrence = occurrence;
|
if (occurrence) data.occurrence = occurrence;
|
||||||
const rewardType = fd.get('rewardType');
|
const rewardType = fd.get('rewardType');
|
||||||
@@ -101,6 +104,7 @@ export const actions = {
|
|||||||
name: fd.get('name'),
|
name: fd.get('name'),
|
||||||
target: fd.get('target'),
|
target: fd.get('target'),
|
||||||
type: fd.get('type'),
|
type: fd.get('type'),
|
||||||
|
thresholdType: fd.get('thresholdType') || 'points',
|
||||||
occurrence: fd.get('occurrence'),
|
occurrence: fd.get('occurrence'),
|
||||||
rewardType: fd.get('rewardType'),
|
rewardType: fd.get('rewardType'),
|
||||||
rewardValue: fd.get('rewardValue'),
|
rewardValue: fd.get('rewardValue'),
|
||||||
@@ -136,6 +140,8 @@ export const actions = {
|
|||||||
if (target) data.target = target;
|
if (target) data.target = target;
|
||||||
const type = fd.get('type');
|
const type = fd.get('type');
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
|
const thresholdType = fd.get('thresholdType');
|
||||||
|
if (thresholdType) data.thresholdType = thresholdType;
|
||||||
const occurrence = fd.get('occurrence');
|
const occurrence = fd.get('occurrence');
|
||||||
if (occurrence) data.occurrence = occurrence;
|
if (occurrence) data.occurrence = occurrence;
|
||||||
const rewardType = fd.get('rewardType');
|
const rewardType = fd.get('rewardType');
|
||||||
@@ -185,6 +191,7 @@ export const actions = {
|
|||||||
description: fd.get('description') || '',
|
description: fd.get('description') || '',
|
||||||
target: fd.get('target'),
|
target: fd.get('target'),
|
||||||
type: fd.get('type'),
|
type: fd.get('type'),
|
||||||
|
thresholdType: fd.get('thresholdType') || 'points',
|
||||||
occurrence: fd.get('occurrence'),
|
occurrence: fd.get('occurrence'),
|
||||||
rewardType: fd.get('rewardType'),
|
rewardType: fd.get('rewardType'),
|
||||||
rewardValue: fd.get('rewardValue'),
|
rewardValue: fd.get('rewardValue'),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
target: 'individual' as string,
|
target: 'individual' as string,
|
||||||
memberId: '' as string,
|
memberId: '' as string,
|
||||||
type: 'threshold' as string,
|
type: 'threshold' as string,
|
||||||
|
thresholdType: 'points' as string,
|
||||||
occurrence: 'recurring' as string,
|
occurrence: 'recurring' as string,
|
||||||
rewardType: 'points' as string,
|
rewardType: 'points' as string,
|
||||||
rewardValue: '100',
|
rewardValue: '100',
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
target: 'individual' as string,
|
target: 'individual' as string,
|
||||||
memberId: '' as string,
|
memberId: '' as string,
|
||||||
type: 'threshold' as string,
|
type: 'threshold' as string,
|
||||||
|
thresholdType: 'points' as string,
|
||||||
occurrence: 'recurring' as string,
|
occurrence: 'recurring' as string,
|
||||||
rewardType: 'points' as string,
|
rewardType: 'points' as string,
|
||||||
rewardValue: '100',
|
rewardValue: '100',
|
||||||
@@ -69,6 +71,8 @@
|
|||||||
|
|
||||||
let showPeriod = $derived(editVals.occurrence !== 'once');
|
let showPeriod = $derived(editVals.occurrence !== 'once');
|
||||||
let showCriteria = $derived(editVals.type !== 'manual');
|
let showCriteria = $derived(editVals.type !== 'manual');
|
||||||
|
let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent');
|
||||||
|
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent');
|
||||||
|
|
||||||
let templateConfigs = $derived(templates as BonusTemplate[]);
|
let templateConfigs = $derived(templates as BonusTemplate[]);
|
||||||
let allBonusConfigs = $derived(
|
let allBonusConfigs = $derived(
|
||||||
@@ -88,6 +92,15 @@
|
|||||||
|
|
||||||
const username = $derived(page.params.username);
|
const username = $derived(page.params.username);
|
||||||
|
|
||||||
|
// Pocket money: seeded for every child, but the amount (rewardValue) starts
|
||||||
|
// empty until the parent sets it on this page. The admin dashboard links
|
||||||
|
// here with ?s=pocketmoney to surface this mandatory teaching notice.
|
||||||
|
let pocketNotice = $derived(page.url.searchParams.get('s') === 'pocketmoney');
|
||||||
|
let pocketConfigs = $derived(
|
||||||
|
(configs as BonusConfig[]).filter((c: BonusConfig) => c.isPocketMoney)
|
||||||
|
);
|
||||||
|
let hasUnsetPocket = $derived(pocketConfigs.some((c: BonusConfig) => !c.rewardValue));
|
||||||
|
|
||||||
function memberName(id: string): string {
|
function memberName(id: string): string {
|
||||||
const m = members.find((m) => m.id === id);
|
const m = members.find((m) => m.id === id);
|
||||||
return m?.name || 'Unknown';
|
return m?.name || 'Unknown';
|
||||||
@@ -131,6 +144,7 @@
|
|||||||
target: targetType,
|
target: targetType,
|
||||||
memberId: '',
|
memberId: '',
|
||||||
type: tpl.type,
|
type: tpl.type,
|
||||||
|
thresholdType: tpl.thresholdType || 'points',
|
||||||
occurrence: tpl.occurrence,
|
occurrence: tpl.occurrence,
|
||||||
rewardType: tpl.rewardType,
|
rewardType: tpl.rewardType,
|
||||||
rewardValue: tpl.rewardValue,
|
rewardValue: tpl.rewardValue,
|
||||||
@@ -186,6 +200,7 @@
|
|||||||
target: cfg.target,
|
target: cfg.target,
|
||||||
memberId: cfg.memberId || '',
|
memberId: cfg.memberId || '',
|
||||||
type: cfg.type,
|
type: cfg.type,
|
||||||
|
thresholdType: cfg.thresholdType || 'points',
|
||||||
occurrence: cfg.occurrence,
|
occurrence: cfg.occurrence,
|
||||||
rewardType: cfg.rewardType,
|
rewardType: cfg.rewardType,
|
||||||
rewardValue: cfg.rewardValue,
|
rewardValue: cfg.rewardValue,
|
||||||
@@ -205,6 +220,26 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Bonuses" hero />
|
<ViewHeader title="Bonuses" hero />
|
||||||
|
|
||||||
|
{#if pocketNotice}
|
||||||
|
<CardGrid>
|
||||||
|
<Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}>
|
||||||
|
<div class="pocket-notice">
|
||||||
|
<strong>Pocket Money</strong>
|
||||||
|
{#if hasUnsetPocket}
|
||||||
|
<p>
|
||||||
|
Every child has a <em>Pocket Money</em> droplet. Set how it's awarded — pick the
|
||||||
|
amount (£) and the % of weekly chores they must complete to earn it — then save. Until
|
||||||
|
you set the amount, pocket money is paused for that child.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>All Pocket Money droplets are set. Children will earn pocket money as they complete chores.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={3}>
|
<Card cols={3}>
|
||||||
<div class="template-section">
|
<div class="template-section">
|
||||||
@@ -214,10 +249,6 @@
|
|||||||
<div class="template-card" draggable="true" ondragstart={(e) => handleDragStart(e, cfg)}>
|
<div class="template-card" draggable="true" ondragstart={(e) => handleDragStart(e, cfg)}>
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<strong>{cfg.name}</strong>
|
<strong>{cfg.name}</strong>
|
||||||
<form method="POST" action="?/deleteTemplate" use:enhance>
|
|
||||||
<input type="hidden" name="id" value={cfg.id} />
|
|
||||||
<button type="submit" class="del-btn" title="Delete">×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
|
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
|
||||||
@@ -226,6 +257,9 @@
|
|||||||
{#if cfg.period}
|
{#if cfg.period}
|
||||||
<span class="badge period">{cfg.period}</span>
|
<span class="badge period">{cfg.period}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if cfg.isPocketMoney}
|
||||||
|
<span class="badge pocket">Pocket Money</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="reward-preview">
|
<div class="reward-preview">
|
||||||
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
||||||
@@ -243,6 +277,7 @@
|
|||||||
target: cfg.target,
|
target: cfg.target,
|
||||||
memberId: '',
|
memberId: '',
|
||||||
type: cfg.type,
|
type: cfg.type,
|
||||||
|
thresholdType: cfg.thresholdType || 'points',
|
||||||
occurrence: cfg.occurrence,
|
occurrence: cfg.occurrence,
|
||||||
rewardType: cfg.rewardType,
|
rewardType: cfg.rewardType,
|
||||||
rewardValue: cfg.rewardValue,
|
rewardValue: cfg.rewardValue,
|
||||||
@@ -277,6 +312,9 @@
|
|||||||
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
|
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
|
||||||
<span class="badge">{cfg.type}</span>
|
<span class="badge">{cfg.type}</span>
|
||||||
<span class="badge">{cfg.occurrence}</span>
|
<span class="badge">{cfg.occurrence}</span>
|
||||||
|
{#if cfg.isPocketMoney}
|
||||||
|
<span class="badge pocket">Pocket Money</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
@@ -292,7 +330,12 @@
|
|||||||
{@const criteria = p?.criteriaValue ?? cfg.criteriaValue ?? 0}
|
{@const criteria = p?.criteriaValue ?? cfg.criteriaValue ?? 0}
|
||||||
{@const current = p?.current ?? 0}
|
{@const current = p?.current ?? 0}
|
||||||
{@const achieved = p?.achieved ?? false}
|
{@const achieved = p?.achieved ?? false}
|
||||||
{@const pct = criteria > 0 ? Math.min(100, (current / criteria) * 100) : 0}
|
{@const isPct = cfg.thresholdType === 'percent'}
|
||||||
|
{@const pct = isPct
|
||||||
|
? Math.min(100, current)
|
||||||
|
: criteria > 0
|
||||||
|
? Math.min(100, (current / criteria) * 100)
|
||||||
|
: 0}
|
||||||
<div class="member-progress">
|
<div class="member-progress">
|
||||||
<span class="mname" style="color:{m.color}">{m.name}</span>
|
<span class="mname" style="color:{m.color}">{m.name}</span>
|
||||||
{#if criteria > 0}
|
{#if criteria > 0}
|
||||||
@@ -345,7 +388,9 @@
|
|||||||
class="bar-fill"
|
class="bar-fill"
|
||||||
style="width:{Math.min(
|
style="width:{Math.min(
|
||||||
100,
|
100,
|
||||||
(p.current / Math.max(1, p.criteriaValue)) * 100
|
cfg.thresholdType === 'percent'
|
||||||
|
? p.current
|
||||||
|
: (p.current / Math.max(1, p.criteriaValue)) * 100
|
||||||
)}%"
|
)}%"
|
||||||
class:met={p.achieved}
|
class:met={p.achieved}
|
||||||
></div>
|
></div>
|
||||||
@@ -391,14 +436,16 @@
|
|||||||
<span class="mname" style="color:{team.memberColor}">{team.memberName}</span>
|
<span class="mname" style="color:{team.memberColor}">{team.memberName}</span>
|
||||||
{#if team.criteriaValue > 0}
|
{#if team.criteriaValue > 0}
|
||||||
<div class="bar-wrap">
|
<div class="bar-wrap">
|
||||||
<div
|
<div
|
||||||
class="bar-fill"
|
class="bar-fill"
|
||||||
style="width:{Math.min(
|
style="width:{Math.min(
|
||||||
100,
|
100,
|
||||||
(team.current / Math.max(1, team.criteriaValue)) * 100
|
cfg.thresholdType === 'percent'
|
||||||
|
? team.current
|
||||||
|
: (team.current / Math.max(1, team.criteriaValue)) * 100
|
||||||
)}%"
|
)}%"
|
||||||
class:met={team.achieved}
|
class:met={team.achieved}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="stat">
|
<span class="stat">
|
||||||
@@ -449,20 +496,43 @@
|
|||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if showCriteriaForCreate}
|
{#if createVals.type === 'threshold'}
|
||||||
<label
|
<label
|
||||||
>Target Value
|
>Threshold by
|
||||||
<input
|
<select name="thresholdType" bind:value={createVals.thresholdType}>
|
||||||
type="number"
|
<option value="points">Points</option>
|
||||||
name="criteriaValue"
|
<option value="percent">% of chores done</option>
|
||||||
bind:value={createVals.criteriaValue}
|
</select>
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span class="hint"
|
|
||||||
>{createVals.type === 'threshold' ? 'Points to reach' : 'Number of chores'}</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showCriteriaForCreate}
|
||||||
|
{#if createIsPercent}
|
||||||
|
<label
|
||||||
|
>Chores completed (%)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
name="criteriaValue"
|
||||||
|
bind:value={createVals.criteriaValue}
|
||||||
|
/>
|
||||||
|
<span class="hint">{createVals.criteriaValue}% of chores this week</span>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<label
|
||||||
|
>Target Value
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="criteriaValue"
|
||||||
|
bind:value={createVals.criteriaValue}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="hint"
|
||||||
|
>{createVals.type === 'threshold' ? 'Points to reach' : 'Number of chores'}</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<label
|
<label
|
||||||
>Occurrence
|
>Occurrence
|
||||||
<select name="occurrence" bind:value={createVals.occurrence}>
|
<select name="occurrence" bind:value={createVals.occurrence}>
|
||||||
@@ -572,24 +642,47 @@
|
|||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if showCriteria}
|
{#if editVals.type === 'threshold'}
|
||||||
<label
|
<label
|
||||||
>Target Value
|
>Threshold by
|
||||||
<input
|
<select name="thresholdType" bind:value={editVals.thresholdType}>
|
||||||
type="number"
|
<option value="points">Points</option>
|
||||||
name="criteriaValue"
|
<option value="percent">% of chores done</option>
|
||||||
bind:value={editVals.criteriaValue}
|
</select>
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span class="hint"
|
|
||||||
>{editVals.type === 'threshold'
|
|
||||||
? 'Points to reach'
|
|
||||||
: editVals.type === 'count'
|
|
||||||
? 'Number of chores'
|
|
||||||
: 'N/A'}</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/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>
|
||||||
|
</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>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<label
|
<label
|
||||||
>Occurrence
|
>Occurrence
|
||||||
<select name="occurrence" bind:value={editVals.occurrence}>
|
<select name="occurrence" bind:value={editVals.occurrence}>
|
||||||
@@ -731,7 +824,18 @@
|
|||||||
.template-list {
|
.template-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
margin-bottom: 0.75rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.template-list {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.template-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.template-card {
|
.template-card {
|
||||||
background: white;
|
background: white;
|
||||||
@@ -804,6 +908,23 @@
|
|||||||
.badge.manual {
|
.badge.manual {
|
||||||
background: #f3e8ff;
|
background: #f3e8ff;
|
||||||
}
|
}
|
||||||
|
.badge.pocket {
|
||||||
|
background: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pocket-notice strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.pocket-notice p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
.reward-preview {
|
.reward-preview {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
|||||||
@@ -334,21 +334,6 @@
|
|||||||
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"
|
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"
|
||||||
>✎</button
|
>✎</button
|
||||||
>
|
>
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteTemplate"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ result, formData }) => {
|
|
||||||
if (result.type === 'success') {
|
|
||||||
const id = formData.get('id');
|
|
||||||
templates = templates.filter((t) => t.id !== id) as ChoreTemplate[];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={t.id} />
|
|
||||||
<button type="submit" class="del-btn" title="Delete template">×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -542,7 +527,7 @@
|
|||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
<h3>Edit Template: {editingTemplate.name}</h3>
|
<h3>Edit Template: {editingTemplate.name}</h3>
|
||||||
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
|
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
|
||||||
<form method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
|
<form id="updateTplForm" method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
|
||||||
<input name="id" type="hidden" value={editingTemplate.id} />
|
<input name="id" type="hidden" value={editingTemplate.id} />
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
@@ -566,11 +551,35 @@
|
|||||||
Default Value
|
Default Value
|
||||||
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
|
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
|
||||||
</label>
|
</label>
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" onclick={closeEditTemplate}>Cancel</button>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={closeEditTemplate}>Cancel</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, formData }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
const id = formData.get('id');
|
||||||
|
templates = templates.filter((t) => t.id !== id) as ChoreTemplate[];
|
||||||
|
closeEditTemplate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editingTemplate.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="delete-btn"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm('Delete this template? This cannot be undone.')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<button type="submit" form="updateTplForm">Save</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -780,7 +789,17 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.template-list {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.template-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
||||||
@@ -1057,6 +1076,10 @@
|
|||||||
background: #6366f1;
|
background: #6366f1;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
.modal-actions button.delete-btn {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Accordion wrappers ── */
|
/* ── Accordion wrappers ── */
|
||||||
.accordion-wrapper {
|
.accordion-wrapper {
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ export const actions = {
|
|||||||
role: 'parent'
|
role: 'parent'
|
||||||
});
|
});
|
||||||
await pbAdmin.create('settings', { famId: fam.id });
|
await pbAdmin.create('settings', { famId: fam.id });
|
||||||
|
// Seed the family-level POCKET MONEY bonus template (the teaching
|
||||||
|
// droplet). Per-child instances are created when each child joins.
|
||||||
|
await pbAdmin
|
||||||
|
.create('bonus_templates', {
|
||||||
|
famId: fam.id,
|
||||||
|
name: 'POCKET MONEY',
|
||||||
|
description: 'Weekly pocket money for children',
|
||||||
|
target: 'individual',
|
||||||
|
type: 'threshold',
|
||||||
|
thresholdType: 'percent',
|
||||||
|
occurrence: 'recurring',
|
||||||
|
rewardType: 'cash',
|
||||||
|
rewardValue: '',
|
||||||
|
criteriaValue: 50,
|
||||||
|
period: 'weekly',
|
||||||
|
isPocketMoney: true
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SignupError(
|
throw new SignupError(
|
||||||
`Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
|
`Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
|
||||||
|
|||||||
+6
-2
@@ -163,11 +163,13 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
text("description"),
|
text("description"),
|
||||||
select("target", ["individual", "competitive", "collaborative"], true),
|
select("target", ["individual", "competitive", "collaborative"], true),
|
||||||
select("type", ["threshold", "count", "manual"], true),
|
select("type", ["threshold", "count", "manual"], true),
|
||||||
|
select("thresholdType", ["points", "percent"], false),
|
||||||
select("occurrence", ["recurring", "once"], true),
|
select("occurrence", ["recurring", "once"], true),
|
||||||
select("rewardType", ["points", "cash", "prize"], true),
|
select("rewardType", ["points", "cash", "prize"], true),
|
||||||
text("rewardValue", true),
|
text("rewardValue", false),
|
||||||
number("criteriaValue"),
|
number("criteriaValue"),
|
||||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||||
|
bool("isPocketMoney"),
|
||||||
], {
|
], {
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
@@ -208,13 +210,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
text("description"),
|
text("description"),
|
||||||
select("target", ["individual", "competitive", "collaborative"], true),
|
select("target", ["individual", "competitive", "collaborative"], true),
|
||||||
select("type", ["threshold", "count", "manual"], true),
|
select("type", ["threshold", "count", "manual"], true),
|
||||||
|
select("thresholdType", ["points", "percent"], false),
|
||||||
select("occurrence", ["recurring", "once"], true),
|
select("occurrence", ["recurring", "once"], true),
|
||||||
select("rewardType", ["points", "cash", "prize"], true),
|
select("rewardType", ["points", "cash", "prize"], true),
|
||||||
text("rewardValue", true),
|
text("rewardValue", false),
|
||||||
number("criteriaValue"),
|
number("criteriaValue"),
|
||||||
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),
|
||||||
|
bool("isPocketMoney"),
|
||||||
], {
|
], {
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
|
|||||||
Reference in New Issue
Block a user