+
+
+
@@ -384,6 +453,15 @@
.edit-btn:hover { color: #6366f1; }
.del-btn:hover { color: #dc2626; }
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
+ .season-filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
+ .season-filter label { font-size: 0.85rem; color: #374151; font-weight: 500; }
+ .season-filter select { padding: 0.35rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; }
+ .badge.season { background: #e0f2fe; color: #0369a1; }
+ .card-meta { font-size: 0.7rem; color: #9ca3af; flex-shrink: 0; }
+ .season-tags { font-size: 0.7rem; color: #9ca3af; }
+ .season-checklist { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.25rem; }
+ .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; cursor: pointer; }
+ .check-item input[type="checkbox"] { width: auto; margin: 0; }
.template { cursor: grab; border-left: 3px solid #6366f1; }
.template:active { cursor: grabbing; opacity: 0.8; }
diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte
index 147373d..e8fdb8c 100644
--- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte
+++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte
@@ -23,12 +23,14 @@
}
function rewardStatus(r: any): string {
- if (!r.claimed) return 'Outstanding';
+ if (r.status === 'unclaimed') return 'Outstanding';
+ if (r.status === 'requested') return 'Requested';
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed';
}
- function isOutstanding(r: any): boolean { return !r.claimed; }
+ function isOutstanding(r: any): boolean { return r.status === 'unclaimed'; }
+ function isRequested(r: any): boolean { return r.status === 'requested'; }
let sorted = $derived([...rewards].sort((a: any, b: any) => {
const da = a.date || a.id || '';
@@ -50,7 +52,7 @@
let totalOutstanding = $derived(
rewards
- .filter((r: any) => isOutstanding(r))
+ .filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
)
@@ -81,7 +83,7 @@
{#each sorted as r}
{@const outstanding = isOutstanding(r)}
-
+
| {r.date?.slice(0, 10) || '—'} |
@@ -90,7 +92,7 @@
| {r.label} |
{rewardAmount(r)} |
-
+
{rewardStatus(r)}
|
@@ -129,9 +131,11 @@
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
tr.claimed { opacity: 0.5; }
tr.claimed td { color: #9ca3af; }
+ tr.requested { background: #eff6ff; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
+ .status-badge.requested-status { background: #dbeafe; color: #1e40af; }
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts
index bfdff35..9c15880 100644
--- a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts
+++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts
@@ -5,11 +5,12 @@ import type { RequestEvent } from '@sveltejs/kit';
export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
- const [members, fam] = await Promise.all([
+ const [members, fam, seasons] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.fam(event, famId),
+ hono.admin.list(event, 'seasons', famId),
]);
- return { members, fam };
+ return { members, fam, seasons };
}
export const actions = {
@@ -54,4 +55,59 @@ export const actions = {
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
return await hono.admin.updatePayday(event, famId, payday);
},
+
+ createSeason: async (event: RequestEvent) => {
+ if (!event.locals.session) throw redirect(303, '/login');
+ const famId = event.locals.session.famId;
+ const fd = await event.request.formData();
+ const name = fd.get('name') as string;
+ const color = fd.get('color') as string;
+ if (!name) return { error: 'Name required' };
+ return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true });
+ },
+
+ deleteSeason: async (event: RequestEvent) => {
+ if (!event.locals.session) throw redirect(303, '/login');
+ const famId = event.locals.session.famId;
+ const fd = await event.request.formData();
+ const id = fd.get('id') as string;
+ if (!id) return { error: 'Season ID required' };
+
+ const assigned = await hono.admin.list(event, 'assigned-chores', famId);
+ const toDelete = (Array.isArray(assigned) ? assigned : [])
+ .filter((a: any) => a.seasonIds?.includes(id));
+ const deletedIds = toDelete.map((a: any) => a.id);
+
+ await Promise.all(toDelete.map((a: any) =>
+ hono.admin.remove(event, 'assigned-chores', famId, a.id)
+ ));
+
+ await hono.admin.remove(event, 'seasons', famId, id);
+
+ return { deletedChoreIds: deletedIds };
+ },
+
+ completeWeek: async (event: RequestEvent) => {
+ if (!event.locals.session) throw redirect(303, '/login');
+ const famId = event.locals.session.famId;
+ try {
+ const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
+ return { success: true, result };
+ } catch (e) {
+ return { error: e instanceof Error ? e.message : 'Failed to complete week' };
+ }
+ },
+
+ generateData: async (event: RequestEvent) => {
+ if (!event.locals.session) throw redirect(303, '/login');
+ const famId = event.locals.session.famId;
+ const fd = await event.request.formData();
+ const days = parseInt(fd.get('days') as string || '7', 10);
+ try {
+ const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
+ return { success: true, result };
+ } catch (e) {
+ return { error: e instanceof Error ? e.message : 'Failed to generate data' };
+ }
+ },
};
diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte
index 16670bc..67ef1b0 100644
--- a/frontend/src/routes/[fam]/[username]/settings/+page.svelte
+++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte
@@ -28,6 +28,7 @@ import { onDestroy } from 'svelte';
)
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
+ let deletingSeason = $state(null)
function copy(url: string) {
navigator.clipboard.writeText(url)
@@ -140,12 +141,73 @@ import { onDestroy } from 'svelte';
They will set up their own password on first login.
+
+ Group chores into seasons. Toggle seasons on/off from the top nav.
+
+
+
+
+ {#each data.seasons as s}
+ -
+
+ {s.name}
+
+
+ {/each}
+
+
+
+
+ {#if deletingSeason}
+ deletingSeason = null} role="presentation">
+
e.stopPropagation()} role="dialog">
+
Delete "{deletingSeason.name}"?
+
All chores assigned to this season will also be removed. This cannot be undone.
+
+
+
+ {/if}
+
+
+ {#if data.fam?.featureFlags?.debugMode}
+
+ Debug mode is enabled. These tools are for development and testing only.
+
+
+
+
+
+ {/if}
diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts
index 8d870e4..e04bf59 100644
--- a/frontend/src/routes/admin/+page.server.ts
+++ b/frontend/src/routes/admin/+page.server.ts
@@ -1,23 +1,81 @@
import { pbAdmin } from '$lib/server/pb-admin';
+import { redirect, fail } from '@sveltejs/kit';
+import { PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
+import type { Actions, PageServerLoad } from './$types';
-export async function load() {
- const fams = await pbAdmin.getList('fams');
- const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
- const [members, rewards] = await Promise.all([
- pbAdmin.getList('members', `famId = '${fam.id}'`),
- pbAdmin.getList('rewards', `famId = '${fam.id}'`),
- ]);
- return {
- id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
- memberCount: members.length,
- claimedRewards: (rewards as any[]).filter((r: any) => r.claimed).length,
- totalRewards: rewards.length,
- };
- }));
+export const load: PageServerLoad = async ({ cookies }) => {
+ const session = cookies.get('platform_session');
+ if (!session) {
+ return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
+ }
- const totalFams = fams.length;
- const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
- const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
+ try {
+ const fams = await pbAdmin.getList('fams');
+ const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
+ const [members, rewards, famAdmins] = await Promise.all([
+ pbAdmin.getList('members', `famId = '${fam.id}'`),
+ pbAdmin.getList('rewards', `famId = '${fam.id}'`),
+ pbAdmin.getList('fam_admins', `famId = '${fam.id}'`),
+ ]);
+ return {
+ id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
+ memberCount: members.length,
+ requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
+ totalRewards: rewards.length,
+ parentEmail: (famAdmins as any[])?.[0]?.email || '',
+ featureFlags: fam.featureFlags || {},
+ };
+ }));
- return { fams: famsWithStats, totalFams, totalMembers, totalRewards };
-}
+ const totalFams = fams.length;
+ const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
+ const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
+
+ return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards };
+ } catch {
+ return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
+ }
+};
+
+export const actions: Actions = {
+ login: async ({ request, cookies }) => {
+ const fd = await request.formData();
+ const email = fd.get('email') as string;
+ const password = fd.get('password') as string;
+
+ if (email === PB_EMAIL && password === PB_PASSWORD) {
+ cookies.set('platform_session', 'authenticated', {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24, // 24 hours
+ });
+ return { success: true };
+ }
+ return fail(400, { error: 'Invalid credentials' });
+ },
+
+ logout: async ({ cookies }) => {
+ cookies.delete('platform_session', { path: '/' });
+ throw redirect(303, '/admin');
+ },
+
+ toggleFeatureFlag: async ({ request, cookies }) => {
+ const session = cookies.get('platform_session');
+ if (!session) return fail(401, { error: 'Not authenticated' });
+
+ const fd = await request.formData();
+ const famId = fd.get('famId') as string;
+ const flag = fd.get('flag') as string;
+
+ try {
+ const fam = await pbAdmin.getOne('fams', famId);
+ const flags = fam.featureFlags || {};
+ flags[flag] = !flags[flag];
+ await pbAdmin.update('fams', famId, { featureFlags: flags });
+ return { success: true };
+ } catch (e) {
+ return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
+ }
+ },
+};
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte
index cc08a88..9bc98bd 100644
--- a/frontend/src/routes/admin/+page.svelte
+++ b/frontend/src/routes/admin/+page.svelte
@@ -1,37 +1,226 @@
-Super Admin
+{#if !data.authenticated}
+
+
+
Platform Admin
+
Sign in to access the admin dashboard
-
-
{data.totalFams} Families
-
{data.totalMembers} Members
-
{data.totalRewards} Rewards
-
+ {#if form?.error}
+
{form.error}
+ {/if}
-
Families
-
- | Name | Slug | Members | Rewards | Invite Code |
-
- {#each data.fams as fam}
-
- | {fam.name} |
- {fam.slug} |
- {fam.memberCount} |
- {fam.claimedRewards}/{fam.totalRewards} |
- {fam.inviteCode} |
-
- {/each}
-
-
+
+
+
+{:else}
+
+
+
+
+
+
+ {data.totalFams}
+ Families
+
+
+ {data.totalMembers}
+ Members
+
+
+ {data.totalRewards}
+ Rewards
+
+
+
+
+
+
+
+
+ | Name |
+ Parent |
+ Members |
+ Claims |
+ Debug |
+ Actions |
+
+
+
+ {#each data.fams as fam}
+
+ |
+ {fam.name}
+ /{fam.slug}
+ |
+ {fam.parentEmail || '—'} |
+ {fam.memberCount} |
+
+ {#if fam.requestedRewards > 0}
+ {fam.requestedRewards} pending
+ {:else}
+ None
+ {/if}
+ |
+
+
+ |
+
+ Dashboard
+ View
+ |
+
+ {/each}
+
+
+
+
+
+
+{/if}
diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts
index 2801cd2..8f16742 100644
--- a/proxy/scripts/seed.ts
+++ b/proxy/scripts/seed.ts
@@ -131,6 +131,7 @@ async function main() {
text("inviteCode"),
text("stripeCustomerId"),
jsonField("featureFlags"),
+ jsonField("seasons"),
],
});
@@ -263,8 +264,9 @@ async function main() {
text("label", true),
number("value", true),
select("rewardType", ["cash", "prize", "points"], true),
- bool("claimed"),
+ select("status", ["unclaimed", "requested", "claimed"], true),
date("claimedAt"),
+ date("requestedAt"),
text("date"),
],
});
@@ -285,6 +287,25 @@ async function main() {
select("type", ["points", "money"], true),
number("value", true),
text("customName"),
+ jsonField("seasonIds"),
+ ],
+ });
+
+ ids.seasons = await createCollection(token, {
+ name: "seasons",
+ type: "base",
+ listRule: "",
+ viewRule: "",
+ createRule: null,
+ updateRule: null,
+ deleteRule: null,
+ fields: [
+ rel("famId", ids.fams!, true),
+ text("name", true),
+ text("color"),
+ bool("active"),
+ date("autoDisable"),
+ date("autoStart"),
],
});
diff --git a/proxy/src/index.ts b/proxy/src/index.ts
index 32e00e2..a26e8fc 100644
--- a/proxy/src/index.ts
+++ b/proxy/src/index.ts
@@ -326,12 +326,53 @@ app.patch("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => {
app.delete("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => {
try {
- const { id } = c.req.param();
+ const { famId, id } = c.req.param();
+ const comps = await pb.getList("completions", `famId = '${famId}'`);
+ const toDelete = (comps.items || []).filter((c: any) => c.assignedChoreId === id);
+ for (const comp of toDelete) {
+ await pb.delete("completions", comp.id);
+ }
await pb.delete("assigned_chores", id);
return c.json({ ok: true });
} catch (err) { return handleError(c, err); }
});
+// ── Admin CRUD: Seasons ──────────────────────────
+
+app.get("/api/admin/:famId/seasons", requireAdmin, async (c) => {
+ try {
+ const { famId } = c.req.param();
+ const data = await pb.getList("seasons", `famId = '${famId}'`);
+ return c.json(data.items);
+ } catch (err) { return handleError(c, err); }
+});
+
+app.post("/api/admin/:famId/seasons", requireAdmin, async (c) => {
+ try {
+ const { famId } = c.req.param();
+ const body = await c.req.json();
+ const record = await pb.create("seasons", { famId, ...body });
+ return c.json(record);
+ } catch (err) { return handleError(c, err); }
+});
+
+app.patch("/api/admin/:famId/seasons/:id", requireAdmin, async (c) => {
+ try {
+ const { id } = c.req.param();
+ const body = await c.req.json();
+ const record = await pb.update("seasons", id, body);
+ return c.json(record);
+ } catch (err) { return handleError(c, err); }
+});
+
+app.delete("/api/admin/:famId/seasons/:id", requireAdmin, async (c) => {
+ try {
+ const { id } = c.req.param();
+ await pb.delete("seasons", id);
+ return c.json({ ok: true });
+ } catch (err) { return handleError(c, err); }
+});
+
// ── Admin: Regenerate invite code ────────────────────────
app.get("/api/admin/:famId/fam", requireAdmin, async (c) => {
@@ -397,9 +438,9 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
let rewardPointsList: any[] = [];
try {
- const pointRewards = await pb.getList("rewards", `famId = '${famId}' && rewardType = 'points' && claimed = true`);
+ const pointRewards = await pb.getList("rewards", `famId = '${famId}' && rewardType = 'points' && status = 'claimed'`);
rewardPointsList = pointRewards.items;
- } catch {} // schema may not have rewardType/claimed yet
+ } catch {} // schema may not have rewardType/status yet
const assignedList = assigned.items;
const completionsList = completions.items;
@@ -446,12 +487,19 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
return sum + (chore?.type === "money" ? Number(chore.value) : 0);
}, 0);
+ // Include claimed cash rewards in money earned
+ let bonusMoney = 0;
+ try {
+ const cashRewards = await pb.getList("rewards", `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed'`);
+ bonusMoney = cashRewards.items.reduce((sum: number, r: any) => sum + Number(r.value), 0);
+ } catch {}
+
return {
memberId: m.id,
memberName: m.name,
memberColor: m.color,
pointsEarned: weekPoints + bonusPoints,
- moneyEarned: weekMoney,
+ moneyEarned: weekMoney + bonusMoney,
choresCompleted: memberCompletions.length,
totalChores: memberAssignments.length,
dayPoints,
@@ -590,10 +638,10 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
current: teamCurrent,
criteriaValue: cfg.criteriaValue || 0,
reward: teamReward
- ? { id: teamReward.id, claimed: teamReward.claimed }
+ ? { id: teamReward.id, status: teamReward.status }
: null,
state: teamReward
- ? (teamReward.claimed ? "claimed" : "unclaimed")
+ ? teamReward.status
: "pending",
achieved: teamReward ? true : false,
});
@@ -626,10 +674,10 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
current,
criteriaValue: cfg.criteriaValue || 0,
reward: memberReward
- ? { id: memberReward.id, claimed: memberReward.claimed }
+ ? { id: memberReward.id, status: memberReward.status }
: null,
state: memberReward
- ? (memberReward.claimed ? "claimed" : "unclaimed")
+ ? memberReward.status
: "pending",
achieved: memberReward ? true : false,
});
@@ -730,7 +778,7 @@ async function evaluateFam(famId: string): Promise {
famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType,
- claimed: cfg.rewardType === "points",
+ status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
});
@@ -755,17 +803,17 @@ async function evaluateFam(famId: string): Promise {
const winner = eligible.sort((a, b) => b.current - a.current)[0];
if (winner && !existingRewards.length) {
const label = cfg.rewardType === "cash"
- ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).toFixed(2)}`
- : `${cfg.name} – ${cfg.rewardValue}`;
- const now = new Date().toISOString();
- await pb.create("rewards", {
- famId, memberId: winner.memberId, bonusConfigId: cfg.id,
- label, value: Number(cfg.rewardValue) || 0,
- rewardType: cfg.rewardType,
- claimed: cfg.rewardType === "points",
- claimedAt: cfg.rewardType === "points" ? now : null,
- date: now.slice(0, 10),
- });
+ ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).toFixed(2)}`
+ : `${cfg.name} – ${cfg.rewardValue}`;
+ const now = new Date().toISOString();
+ await pb.create("rewards", {
+ famId, memberId: m.id, bonusConfigId: cfg.id,
+ label, value: Number(cfg.rewardValue) || 0,
+ rewardType: cfg.rewardType,
+ status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
+ claimedAt: cfg.rewardType === "points" ? now : null,
+ date: now.slice(0, 10),
+ });
}
}
}
@@ -825,7 +873,7 @@ app.post("/api/admin/:famId/bonus-configs/:id/trigger", requireAdmin, async (c)
// Check occurrence limits per target member
for (const m of targetMembers) {
const memberRewards = existingRewards.items.filter((r: any) => r.memberId === m.id);
- if (cfg.occurrence === "once" && memberRewards.some((r: any) => !r.claimed)) {
+ if (cfg.occurrence === "once" && memberRewards.some((r: any) => r.status === "unclaimed")) {
return c.json({ error: `Already issued and pending for ${m.name}` }, 400);
}
if (cfg.occurrence === "recurring" && cfg.period) {
@@ -849,7 +897,7 @@ app.post("/api/admin/:famId/bonus-configs/:id/trigger", requireAdmin, async (c)
famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType,
- claimed: cfg.rewardType === "points",
+ status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
});
@@ -896,6 +944,21 @@ app.post("/api/admin/:famId/bonus-configs/evaluate", requireAdmin, async (c) =>
} catch (err) { return handleError(c, err); }
});
+// ── Member: Get seasons ─────────────────────────────────
+
+app.get("/api/members/seasons", async (c) => {
+ try {
+ const deviceToken = c.req.header("x-device-token");
+ if (!deviceToken) return c.json({ error: "x-device-token required" }, 400);
+ const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex");
+ const members = await pb.getList("members", `deviceToken = '${hashHex}'`);
+ const member = members.items?.[0];
+ if (!member) return c.json({ error: "Invalid device token" }, 401);
+ const seasons = await pb.getList("seasons", `famId = '${member.famId}'`);
+ return c.json({ seasons: seasons.items, famId: member.famId });
+ } catch (err) { return handleError(c, err); }
+});
+
// ── Member: Get chores (for kanban) ──────────────────────
app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
@@ -969,11 +1032,35 @@ app.post("/api/admin/:famId/rewards/:id/claim", requireAdmin, async (c) => {
try {
const { id } = c.req.param();
const now = new Date().toISOString();
- const record = await pb.update("rewards", id, { claimed: true, claimedAt: now });
+ const record = await pb.update("rewards", id, { status: "claimed", claimedAt: now });
return c.json(record);
} catch (err) { return handleError(c, err); }
});
+// ── Admin: Issue all requested rewards for a member ─────
+
+app.post("/api/admin/:famId/rewards/issue-all", requireAdmin, async (c) => {
+ try {
+ const { famId } = c.req.param();
+ const body = await c.req.json();
+ const { memberId, message } = body;
+ if (!memberId) return c.json({ error: "memberId required" }, 400);
+ const now = new Date().toISOString();
+ // Find all requested rewards for this member
+ const rewards = await pb.getList("rewards", `famId = '${famId}' && memberId = '${memberId}' && status = 'requested'`);
+ let count = 0;
+ for (const r of rewards.items) {
+ await pb.update("rewards", r.id, { status: "claimed", claimedAt: now });
+ count++;
+ }
+ // Send notification to member if message provided
+ if (message && count > 0) {
+ await pb.create("notifications", { famId, memberId, message, read: false });
+ }
+ return c.json({ count });
+ } catch (err) { return handleError(c, err); }
+});
+
// ── Admin: Revoke a completion ────────────────────────
app.post("/api/admin/:famId/completions/:id/revoke", requireAdmin, async (c) => {
@@ -1042,16 +1129,40 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
const famId = c.get("famId");
const memberId = c.get("memberId");
const now = new Date().toISOString();
- const record = await pb.update("rewards", id, { claimed: true, claimedAt: now });
+ const record = await pb.update("rewards", id, { status: "requested", requestedAt: now });
// Create notification for admin
const members = await pb.getList("members", `famId = '${famId}' && id = '${memberId}'`);
const memberName = members.items?.[0]?.name || "Unknown";
- const msg = `Claim made by ${memberName}`;
+ const msg = `Claim requested by ${memberName}`;
await pb.create("notifications", { famId, memberId, message: msg, read: false });
return c.json(record);
} catch (err) { return handleError(c, err); }
});
+// ── Member: Request all unclaimed rewards ────────────────
+
+app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
+ try {
+ const famId = c.get("famId");
+ const memberId = c.get("memberId");
+ const now = new Date().toISOString();
+ // Find all unclaimed rewards for this member
+ const rewards = await pb.getList("rewards", `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`);
+ let count = 0;
+ for (const r of rewards.items) {
+ await pb.update("rewards", r.id, { status: "requested", requestedAt: now });
+ count++;
+ }
+ if (count > 0) {
+ const members = await pb.getList("members", `famId = '${famId}' && id = '${memberId}'`);
+ const memberName = members.items?.[0]?.name || "Unknown";
+ const msg = `${count} claim(s) requested by ${memberName}`;
+ await pb.create("notifications", { famId, memberId, message: msg, read: false });
+ }
+ return c.json({ count });
+ } catch (err) { return handleError(c, err); }
+});
+
// ── Member: Notifications ──────────────────────────────
app.get("/api/members/notifications", requireDeviceToken, async (c) => {
@@ -1071,6 +1182,149 @@ app.post("/api/members/notifications/:id/dismiss", requireDeviceToken, async (c)
} catch (err) { return handleError(c, err); }
});
+// ── Admin: Complete week (snapshot to weekly_history) ────
+
+app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
+ try {
+ const { famId } = c.req.param();
+ const payday = await getFamPayday(famId);
+ const ws = weekStart(payday);
+
+ // Evaluate weekly bonus configs first
+ await evaluateFam(famId);
+
+ // Fetch data for summary
+ const [members, assigned, completions] = await Promise.all([
+ pb.getList("members", `famId = '${famId}'`),
+ pb.getList("assigned_chores", `famId = '${famId}'`),
+ pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
+ ]);
+
+ let rewardPointsList: any[] = [];
+ let rewardCashList: any[] = [];
+ try {
+ const [pointsRewards, cashRewards] = await Promise.all([
+ pb.getList("rewards", `famId = '${famId}' && rewardType = 'points' && status = 'claimed'`),
+ pb.getList("rewards", `famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`),
+ ]);
+ rewardPointsList = pointsRewards.items;
+ rewardCashList = cashRewards.items;
+ } catch {}
+
+ const assignedList = assigned.items;
+ const historyRecords = [];
+
+ for (const m of members.items) {
+ const memberCompletions = completions.items.filter((c: any) => c.memberId === m.id);
+
+ const weekPoints = 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);
+
+ const weekMoney = memberCompletions.reduce((sum: number, c: any) => {
+ const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
+ return sum + (chore?.type === "money" ? Number(chore.value) : 0);
+ }, 0);
+
+ const bonusPoints = rewardPointsList
+ .filter((r: any) => r.memberId === m.id)
+ .reduce((sum: number, r: any) => sum + Number(r.value), 0);
+
+ const bonusMoney = rewardCashList
+ .filter((r: any) => r.memberId === m.id)
+ .reduce((sum: number, r: any) => sum + Number(r.value), 0);
+
+ // Upsert weekly_history
+ const existing = await pb.getList("weekly_history", `famId = '${famId}' && memberId = '${m.id}' && weekStart = '${ws}'`);
+ const recordData = {
+ famId,
+ memberId: m.id,
+ weekStart: ws,
+ pointsEarned: weekPoints + bonusPoints,
+ moneyEarned: weekMoney + bonusMoney,
+ choresCompleted: memberCompletions.length,
+ bonusEarned: bonusPoints,
+ };
+
+ if (existing.items?.length > 0) {
+ await pb.update("weekly_history", existing.items[0].id, recordData);
+ } else {
+ const record = await pb.create("weekly_history", recordData);
+ historyRecords.push(record);
+ }
+ }
+
+ return c.json({ weekStart: ws, historyRecords, memberCount: members.items.length });
+ } catch (err) { return handleError(c, err); }
+});
+
+// ── Admin: Generate test data ────────────────────────────
+
+app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => {
+ try {
+ const { famId } = c.req.param();
+ const body = await c.req.json().catch(() => ({}));
+ const days = body.days || 7;
+
+ const [members, templates] = await Promise.all([
+ pb.getList("members", `famId = '${famId}'`),
+ pb.getList("chore_templates", `famId = '${famId}'`),
+ ]);
+
+ if (!members.items.length) return c.json({ error: "No members found" }, 400);
+ if (!templates.items.length) return c.json({ error: "No templates found" }, 400);
+
+ let completionsCreated = 0;
+ const today = new Date();
+
+ for (let d = 0; d < days; d++) {
+ const date = new Date(today);
+ date.setDate(date.getDate() - d);
+ const dateStr = date.toISOString().slice(0, 10);
+
+ for (const m of members.items) {
+ // Randomly complete 50-100% of templates
+ const completionRate = 0.5 + Math.random() * 0.5;
+ for (const t of templates.items) {
+ if (Math.random() > completionRate) continue;
+
+ // Create assigned_chores if needed
+ let assigned = await pb.getList("assigned_chores", `famId = '${famId}' && memberId = '${m.id}' && templateId = '${t.id}'`);
+ let assignedId;
+ if (assigned.items?.length > 0) {
+ assignedId = assigned.items[0].id;
+ } else {
+ const record = await pb.create("assigned_chores", {
+ famId,
+ memberId: m.id,
+ templateId: t.id,
+ frequency: t.defaultFrequency || "daily",
+ type: t.defaultType || "points",
+ value: t.defaultValue || 10,
+ });
+ assignedId = record.id;
+ }
+
+ // Check if already completed
+ const existing = await pb.getList("completions", `famId = '${famId}' && memberId = '${m.id}' && assignedChoreId = '${assignedId}' && date = '${dateStr}'`);
+ if (existing.items?.length > 0) continue;
+
+ await pb.create("completions", {
+ famId,
+ memberId: m.id,
+ assignedChoreId: assignedId,
+ date: dateStr,
+ });
+ completionsCreated++;
+ }
+ }
+ }
+
+ return c.json({ completionsCreated, days });
+ } catch (err) { return handleError(c, err); }
+});
+
// ── Start server ─────────────────────────────────────────
const port = parseInt(process.env.PROXY_PORT || "3456", 10);
diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts
index b1808f3..cf3859b 100644
--- a/proxy/src/migrate.ts
+++ b/proxy/src/migrate.ts
@@ -553,5 +553,199 @@ export async function migrate(): Promise {
}
}
+ // ── 16. Add seasons json field to fams ──
+ {
+ const c = await getCollection("fams");
+ if (c) {
+ const hasField = c.fields.some((f: any) => f.name === "seasons");
+ if (!hasField) {
+ console.log("[migrate] Adding seasons to fams...");
+ c.fields.push({ name: "seasons", type: "json" });
+ await updateCollection(c.id, {
+ name: "fams", type: "base",
+ listRule: c.listRule, viewRule: c.viewRule,
+ createRule: c.createRule, updateRule: c.updateRule,
+ deleteRule: c.deleteRule, fields: c.fields,
+ });
+ } else {
+ console.log(` ↳ fams.seasons already exists`);
+ }
+ }
+ }
+
+ // ── 17. Add seasonIds json field to assigned_chores ──
+ {
+ const c = await getCollection("assigned_chores");
+ if (c) {
+ const hasField = c.fields.some((f: any) => f.name === "seasonIds");
+ if (!hasField) {
+ console.log("[migrate] Adding seasonIds to assigned_chores...");
+ c.fields.push({ name: "seasonIds", type: "json" });
+ await updateCollection(c.id, {
+ name: "assigned_chores", type: "base",
+ listRule: c.listRule, viewRule: c.viewRule,
+ createRule: c.createRule, updateRule: c.updateRule,
+ deleteRule: c.deleteRule, fields: c.fields,
+ });
+ } else {
+ console.log(` ↳ assigned_chores.seasonIds already exists`);
+ }
+ }
+ }
+
+ // ── 18. Create seasons collection if missing ──
+ {
+ const existing = await getCollection("seasons");
+ if (!existing) {
+ console.log("[migrate] Creating seasons collection...");
+ const t = await auth();
+ const famsCol = await getCollection("fams");
+ const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
+ body: JSON.stringify({
+ name: "seasons", type: "base",
+ listRule: "", viewRule: "",
+ createRule: null, updateRule: null, deleteRule: null,
+ fields: [
+ { name: "famId", type: "relation", required: true, maxSelect: 1, collectionId: famsCol?.id || "" },
+ { name: "name", type: "text", required: true },
+ { name: "color", type: "text" },
+ { name: "active", type: "bool" },
+ { name: "autoDisable", type: "date" },
+ { name: "autoStart", type: "date" },
+ ],
+ }),
+ });
+ if (res.ok) console.log(" ✓ Created seasons collection");
+ else console.log(` ↳ seasons collection creation skipped or already exists`);
+ } else {
+ console.log(` ↳ seasons collection already exists`);
+ }
+ }
+
+ // ── 19. Add active bool to existing seasons collection ──
+ {
+ const c = await getCollection("seasons");
+ if (c) {
+ const hasActive = c.fields.some((f: any) => f.name === "active");
+ if (!hasActive) {
+ console.log("[migrate] Adding active bool to seasons...");
+ c.fields.push({ name: "active", type: "bool" });
+ await updateCollection(c.id, {
+ name: "seasons", type: "base",
+ listRule: c.listRule, viewRule: c.viewRule,
+ createRule: c.createRule, updateRule: c.updateRule,
+ deleteRule: c.deleteRule, fields: c.fields,
+ });
+ } else {
+ console.log(` ↳ seasons.active already exists`);
+ }
+ }
+ }
+
+ // ── 20. Backfill active=true from fams.seasons array ──
+ {
+ const t = await auth();
+ const fams = await getCollection("fams");
+ if (fams) {
+ const hasSeasonsField = fams.fields.some((f: any) => f.name === "seasons");
+ if (hasSeasonsField) {
+ console.log("[migrate] Backfilling season active flags from fams.seasons...");
+ const allFams = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=1000`, {
+ headers: { Authorization: `Bearer ${t}` },
+ });
+ const famData = await allFams.json();
+ for (const fam of famData.items || []) {
+ const activeIds = fam.seasons || [];
+ if (activeIds.length > 0) {
+ for (const sid of activeIds) {
+ await fetch(`${PB_ENDPOINT}/api/collections/seasons/records/${sid}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
+ body: JSON.stringify({ active: true }),
+ });
+ }
+ }
+ }
+ console.log(` ↳ backfilled ${famData.items?.length || 0} fams`);
+ } else {
+ console.log(` ↳ fams.seasons already removed`);
+ }
+ }
+ }
+
+ // ── 21. Remove seasons field from fams ──
+ {
+ const c = await getCollection("fams");
+ if (c) {
+ const hasField = c.fields.some((f: any) => f.name === "seasons");
+ if (hasField) {
+ console.log("[migrate] Removing seasons field from fams...");
+ c.fields = c.fields.filter((f: any) => f.name !== "seasons");
+ await updateCollection(c.id, {
+ name: "fams", type: "base",
+ listRule: c.listRule, viewRule: c.viewRule,
+ createRule: c.createRule, updateRule: c.updateRule,
+ deleteRule: c.deleteRule, fields: c.fields,
+ });
+ } else {
+ console.log(` ↳ fams.seasons already removed`);
+ }
+ }
+ }
+
+ // ── 22. Convert rewards.claimed boolean to status select field ──
+ {
+ const c = await getCollection("rewards");
+ if (c) {
+ const hasClaimedField = c.fields.some((f: any) => f.name === "claimed");
+ const hasStatusField = c.fields.some((f: any) => f.name === "status");
+ if (hasClaimedField && !hasStatusField) {
+ console.log("[migrate] Converting rewards.claimed to status field...");
+
+ // Fetch all rewards to backfill status
+ const t = await auth();
+ let page = 1;
+ let total = 0;
+ while (true) {
+ const res = await fetch(`${PB_ENDPOINT}/api/collections/rewards/records?page=${page}&perPage=100`, {
+ headers: { Authorization: `Bearer ${t}` },
+ });
+ const data = await res.json();
+ for (const r of data.items || []) {
+ const status = r.claimed ? "claimed" : "unclaimed";
+ await fetch(`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
+ body: JSON.stringify({ status }),
+ });
+ total++;
+ }
+ if (!data.items || data.items.length < 100) break;
+ page++;
+ }
+ console.log(` ↳ backfilled ${total} rewards with status`);
+
+ // Remove claimed field and add status + requestedAt fields
+ c.fields = c.fields.filter((f: any) => f.name !== "claimed");
+ if (!c.fields.some((f: any) => f.name === "status")) {
+ c.fields.push({ name: "status", type: "select", required: true, values: ["unclaimed", "requested", "claimed"], maxSelect: 1 });
+ }
+ if (!c.fields.some((f: any) => f.name === "requestedAt")) {
+ c.fields.push({ name: "requestedAt", type: "date", required: false });
+ }
+ await updateCollection(c.id, {
+ name: "rewards", type: "base",
+ listRule: c.listRule, viewRule: c.viewRule,
+ createRule: c.createRule, updateRule: c.updateRule,
+ deleteRule: c.deleteRule, fields: c.fields,
+ });
+ } else if (!hasClaimedField) {
+ console.log(` ↳ rewards.claimed already converted to status`);
+ }
+ }
+ }
+
console.log("[migrate] Done");
}
diff --git a/proxy/src/pb.ts b/proxy/src/pb.ts
index 9942ec6..4ffac0d 100644
--- a/proxy/src/pb.ts
+++ b/proxy/src/pb.ts
@@ -56,14 +56,14 @@ export const pb = {
async delete(collection: string, id: string) {
const res = await request("DELETE", `/api/collections/${collection}/records/${id}`);
- if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status}`);
+ const body = await res.text();
+ if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status} ${body}`);
},
async getList(collection: string, filter = "") {
- const params = new URLSearchParams();
- if (filter) params.set("filter", filter);
- params.set("perPage", "200");
- const res = await request("GET", `/api/collections/${collection}/records?${params}`);
+ let path = `/api/collections/${collection}/records?perPage=1000`;
+ if (filter) path += `&filter=${encodeURIComponent(filter)}`;
+ const res = await request("GET", path);
const json = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`);
return json;