add fixes to claims and seasons

This commit is contained in:
JCEEE
2026-07-30 11:12:35 +01:00
parent 57ef30d3a7
commit b254564436
24 changed files with 2517 additions and 704 deletions
+13 -2
View File
@@ -60,13 +60,24 @@
## Data Flow ## Data Flow
### Reads (both roles)
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie).
- **Child (member):** `famStore.init()` fetches all collections via PB SDK — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections.
- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
### Writes (both roles go through Hono proxy)
- **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT) - **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT)
- **Admin CRUD:** Browser → Hono proxy → PB (admin JWT) - **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`)
- **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`)
- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met - **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met
- **Weekly CRON:** Coolify → `GET /api/weekly-cron` on Hono → Hono queries PB, computes summaries, upserts weekly_history - **Weekly CRON:** Coolify → `GET /api/weekly-cron` on Hono → Hono queries PB, computes summaries, upserts weekly_history
- **Stripe donate:** Browser → Hono `/api/stripe/create-checkout` → Stripe → Hono webhook → update fam - **Stripe donate:** Browser → Hono `/api/stripe/create-checkout` → Stripe → Hono webhook → update fam
- **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface - **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface
- **UI reactivity:** Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE
### UI reactivity
- Svelte `$state` / `$derived` / `$effect`
- PB SDK `.subscribe()` for realtime multi-user sync (public reads → works for both roles)
- `famStore.applyRecord()` for instant optimistic UI feedback from form actions
## Env Vars (SvelteKit 3.0.0-next.4) ## Env Vars (SvelteKit 3.0.0-next.4)
+3
View File
@@ -36,4 +36,7 @@ export const memberApi = {
async claimReward(token: string, famId: string, rewardId: string) { async claimReward(token: string, famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId); return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
}, },
async requestAllRewards(token: string, famId: string) {
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
},
}; };
+3 -3
View File
@@ -15,22 +15,22 @@
let navItems = $derived(isParent && memberName let navItems = $derived(isParent && memberName
? [ ? [
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }, { href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon }, { href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon }, { href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }, { href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
] ]
: showChildItems : showChildItems
? [ ? [
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }, { href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
] ]
: [] : []
); );
let footerItems = $derived([ let footerItems = $derived([
{ href: `/${famSlug}`, label: famName, icon: homeIcon }, ...(memberName ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] : []),
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []), ...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon }, { href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
]); ]);
+52 -1
View File
@@ -1,12 +1,36 @@
<script lang="ts"> <script lang="ts">
let { announcement = '', children }: { announcement?: string; children?: any } = $props(); let {
announcement = '',
role = '',
seasons = [],
children,
}: {
announcement?: string
role?: string
seasons?: { id: string; name: string; color: string; active: boolean }[]
children?: any
} = $props();
</script> </script>
<header class="topnav"> <header class="topnav">
<div class="topnav-left">
{#if role}
<span class="role-pill {role}">{role}</span>
{/if}
</div>
<div class="topnav-announcement"> <div class="topnav-announcement">
{#if announcement}<span class="announcement-text">{announcement}</span>{/if} {#if announcement}<span class="announcement-text">{announcement}</span>{/if}
</div> </div>
<div class="topnav-actions"> <div class="topnav-actions">
{#each seasons as s}
<span
class="season-pill"
class:active={s.active}
style="--season-color: {s.color}"
>
{s.name}
</span>
{/each}
{@render children?.()} {@render children?.()}
</div> </div>
</header> </header>
@@ -23,8 +47,35 @@
padding: 0 1.5rem; padding: 0 1.5rem;
z-index: 90; z-index: 90;
transition: left 0.2s; transition: left 0.2s;
gap: 0.75rem;
} }
.topnav-left { display: flex; align-items: center; gap: 0.5rem; }
.topnav-announcement { flex: 1; text-align: center; } .topnav-announcement { flex: 1; text-align: center; }
.announcement-text { font-size: 0.85rem; color: #6b7280; } .announcement-text { font-size: 0.85rem; color: #6b7280; }
.topnav-actions { display: flex; align-items: center; gap: 0.5rem; } .topnav-actions { display: flex; align-items: center; gap: 0.5rem; }
.role-pill {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.03em;
}
.role-pill.parent { background: #fef3c7; color: #b45309; }
.role-pill.child { background: #dbeafe; color: #1d4ed8; }
.season-pill {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1.5px solid var(--season-color, #d1d5db);
background: transparent;
color: #374151;
font-weight: 500;
transition: all 0.15s;
}
.season-pill.active {
background: var(--season-color, #6366f1);
color: #fff;
border-color: var(--season-color, #6366f1);
}
</style> </style>
+9
View File
@@ -65,6 +65,9 @@ export const hono = {
async claimReward(event: RequestEvent, famId: string, rewardId: string) { async claimReward(event: RequestEvent, famId: string, rewardId: string) {
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event)); return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event));
}, },
async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) {
return request('POST', `/api/admin/${famId}/rewards/issue-all`, { memberId, message }, sessionHeaders(event));
},
async bonusConfigs(event: RequestEvent, famId: string) { async bonusConfigs(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
}, },
@@ -107,5 +110,11 @@ export const hono = {
async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) { async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event)); return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
}, },
async updateFam(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/fam`, data, sessionHeaders(event));
},
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
return request(method, path, body, sessionHeaders(event));
},
}, },
}; };
+12
View File
@@ -42,4 +42,16 @@ export const pbAdmin = {
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
return data; return data;
}, },
async update(collection: string, id: string, data: Record<string, unknown>) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(data),
});
const result = await res.json();
if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`);
return result;
},
}; };
+8 -3
View File
@@ -1,10 +1,10 @@
import { pb } from '$lib/pocketbase'; import { pb } from '$lib/pocketbase';
import type { import type {
Member, ChoreTemplate, AssignedChore, Completion, Member, ChoreTemplate, AssignedChore, Completion,
WeeklyHistory, Reward, BonusConfig, Fam, WeeklyHistory, Reward, BonusConfig, Fam, Season,
} from '$lib/types'; } from '$lib/types';
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards'; type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards' | 'seasons';
class FamStore { class FamStore {
fam = $state<Fam | null>(null) fam = $state<Fam | null>(null)
@@ -15,6 +15,7 @@ class FamStore {
history = $state<WeeklyHistory[]>([]) history = $state<WeeklyHistory[]>([])
rewards = $state<Reward[]>([]) rewards = $state<Reward[]>([])
bonusConfigs = $state<BonusConfig[]>([]) bonusConfigs = $state<BonusConfig[]>([])
seasons = $state<Season[]>([])
initialized = $state(false) initialized = $state(false)
famId = $state('') famId = $state('')
@@ -62,7 +63,7 @@ class FamStore {
this.initPromise = (async () => { this.initPromise = (async () => {
try { try {
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] = const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes, seasonsRes] =
await Promise.all([ await Promise.all([
pb.collection('fams').getOne(famId) as Promise<Fam>, pb.collection('fams').getOne(famId) as Promise<Fam>,
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>, pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>,
@@ -71,6 +72,7 @@ class FamStore {
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>, pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>,
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>, pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>, pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>,
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise<Season[]>,
]) ])
this.fam = famRes this.fam = famRes
this.members = membersRes this.members = membersRes
@@ -79,6 +81,7 @@ class FamStore {
this.completions = completionsRes this.completions = completionsRes
this.bonusConfigs = bonusConfigsRes this.bonusConfigs = bonusConfigsRes
this.rewards = rewardsRes this.rewards = rewardsRes
this.seasons = seasonsRes
this.initialized = true this.initialized = true
} catch (e) { } catch (e) {
console.error('FamStore.init failed:', e) console.error('FamStore.init failed:', e)
@@ -101,6 +104,7 @@ class FamStore {
{ collection: 'completions', filter: this.famId }, { collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId }, { collection: 'bonus_configs', filter: this.famId },
{ collection: 'rewards', filter: this.famId }, { collection: 'rewards', filter: this.famId },
{ collection: 'seasons', filter: this.famId },
] ]
const promises = subs.map(({ collection, filter }) => { const promises = subs.map(({ collection, filter }) => {
@@ -136,6 +140,7 @@ class FamStore {
case 'completions': this.completions = apply(this.completions); break case 'completions': this.completions = apply(this.completions); break
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break
case 'rewards': this.rewards = apply(this.rewards); break case 'rewards': this.rewards = apply(this.rewards); break
case 'seasons': this.seasons = apply(this.seasons); break
} }
} }
+15 -1
View File
@@ -8,6 +8,18 @@ export type BonusPeriod = 'weekly' | 'monthly'
export type BonusStatus = 'active' | 'archived' export type BonusStatus = 'active' | 'archived'
export type BonusState = 'pending' | 'unclaimed' | 'claimed' export type BonusState = 'pending' | 'unclaimed' | 'claimed'
export interface Season {
id: string
famId: string
name: string
color: string
active: boolean
autoDisable?: string
autoStart?: string
created: string
updated: string
}
export interface Fam { export interface Fam {
id: string id: string
name: string name: string
@@ -51,6 +63,7 @@ export interface AssignedChore {
type: RewardType type: RewardType
value: number value: number
customName?: string customName?: string
seasonIds?: string[]
created: string created: string
updated: string updated: string
} }
@@ -102,8 +115,9 @@ export interface Reward {
label: string label: string
value: number value: number
rewardType: BonusRewardType rewardType: BonusRewardType
claimed: boolean status: 'unclaimed' | 'requested' | 'claimed'
claimedAt?: string claimedAt?: string
requestedAt?: string
date: string date: string
created: string created: string
updated: string updated: string
+27 -2
View File
@@ -1,4 +1,29 @@
export function load(event) { import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export async function load(event) {
const session = event.locals.session || null; const session = event.locals.session || null;
return { session, isParent: session !== null, role: session?.role || 'child' }; const isParent = session !== null;
let famId = '';
if (isParent) {
famId = session.famId;
} else {
const deviceToken = event.cookies.get('device_token') || '';
if (deviceToken) {
try {
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
headers: { 'x-device-token': deviceToken },
});
if (res.ok) {
const body = await res.json();
famId = body.famId || '';
}
} catch {}
}
}
return { session, isParent, role: session?.role || 'child', famId };
} }
+28 -12
View File
@@ -10,7 +10,9 @@
let session = $state<Session | null>(data.session); let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent); let isParent = $state(data.isParent);
let role = $state(data.role); let role = $state(data.role);
let famName = $derived(famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam); let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
);
// Claim toast watcher (admin only) // Claim toast watcher (admin only)
let claimToast = $state(''); let claimToast = $state('');
@@ -22,31 +24,29 @@
const rewards = famStore.rewards; const rewards = famStore.rewards;
if (!mounted) { if (!mounted) {
mounted = true; mounted = true;
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id)); prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
return; return;
} }
for (const r of rewards) { for (const r of rewards) {
if (r.claimed && !prevClaimedIds.has(r.id)) { if (r.status === 'requested' && !prevClaimedIds.has(r.id)) {
const m = famStore.memberMap().get(r.memberId); const m = famStore.memberMap().get(r.memberId);
const name = m?.name || 'Unknown'; const name = m?.name || 'Unknown';
claimToast = `Claim made by ${name}`; claimToast = `Claim requested by ${name}`;
setTimeout(() => claimToast = '', 5000); setTimeout(() => (claimToast = ''), 5000);
} }
} }
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id)); prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
}); });
onMount(() => { onMount(() => {
initPbFromCookie(); initPbFromCookie();
if (session) { if (page.data.famId) famStore.init(page.data.famId);
famStore.init(session.famId);
}
}); });
</script> </script>
<div class="app-shell"> <div class="app-shell">
<Sidebar {famName} session={data.session} {isParent} {role} /> <Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav /> <TopNav {role} seasons={famStore.seasons} />
<main class="app-main"> <main class="app-main">
{@render children()} {@render children()}
</main> </main>
@@ -57,8 +57,24 @@
</div> </div>
<style> <style>
.claim-toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #059669; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; } .claim-toast {
.app-shell { min-height: 100vh; display: flex; flex-direction: column; } position: fixed;
top: 3.5rem;
right: 1.5rem;
background: #059669;
color: white;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
z-index: 999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadein 0.2s;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-main { .app-main {
margin-left: 220px; margin-left: 220px;
margin-top: 48px; margin-top: 48px;
@@ -92,6 +92,20 @@ export const actions = {
} }
}, },
issueAll: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const memberId = fd.get('memberId') as string;
const message = fd.get('message') as string;
try {
const result = await hono.admin.issueAllRewards(event, famId, memberId, message || undefined);
return { count: result.count };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
}
},
revoke: async (event) => { revoke: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
File diff suppressed because it is too large Load Diff
@@ -57,9 +57,9 @@ import type {
let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') as BonusConfig[]); let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') as BonusConfig[]);
let activeConfigs = $derived(configs.filter((c) => c.phase === 'active') as BonusConfig[]); let activeConfigs = $derived(configs.filter((c) => c.phase === 'active') as BonusConfig[]);
let completedConfigs = $derived(configs.filter((c) => c.phase === 'completed') as BonusConfig[]); let completedConfigs = $derived(configs.filter((c) => c.phase === 'completed') as BonusConfig[]);
let completedRewards = $derived(allRewards.filter((r) => r.claimed) as Reward[]); let completedRewards = $derived(allRewards.filter((r) => r.status === 'claimed') as Reward[]);
let unclaimedRewards = $derived( let unclaimedRewards = $derived(
allRewards.filter((r) => !r.claimed) as Reward[] allRewards.filter((r) => r.status === 'unclaimed') as Reward[]
); );
let progressByConfigId = $derived.by(() => { let progressByConfigId = $derived.by(() => {
const map = new Map<string, BonusConfigWithProgress>(); const map = new Map<string, BonusConfigWithProgress>();
@@ -4,12 +4,13 @@ import { hono } from '$lib/server/hono';
export async function load(event) { export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
const [templates, members, assigned] = await Promise.all([ const [templates, members, assigned, seasons] = await Promise.all([
hono.admin.list(event, 'chore-templates', famId), hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'members', famId), hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId), hono.admin.list(event, 'assigned-chores', famId),
hono.admin.list(event, 'seasons', famId),
]); ]);
return { templates, members, assigned }; return { templates, members, assigned, seasons };
} }
export const actions = { export const actions = {
@@ -80,29 +81,6 @@ export const actions = {
} }
}, },
assignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data = {
memberId: fd.get('memberId'),
templateId: fd.get('templateId'),
frequency: fd.get('frequency'),
type: fd.get('type'),
value: parseFloat(fd.get('value') as string) || 0,
customName: fd.get('customName') || undefined,
};
if (!data.memberId || !data.templateId || !data.frequency || !data.type) {
return fail(400, { error: 'Member, template, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' });
}
},
updateAssignedChore: async (event) => { updateAssignedChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
@@ -113,10 +91,14 @@ export const actions = {
const type = fd.get('type'); const type = fd.get('type');
const value = fd.get('value'); const value = fd.get('value');
const customName = fd.get('customName'); const customName = fd.get('customName');
const seasonIdsRaw = fd.get('seasonIds') as string;
if (frequency) data.frequency = frequency; if (frequency) data.frequency = frequency;
if (type) data.type = type; if (type) data.type = type;
if (value) data.value = parseFloat(value as string) || 0; if (value) data.value = parseFloat(value as string) || 0;
data.customName = (customName as string) || undefined; data.customName = (customName as string) || undefined;
if (seasonIdsRaw || seasonIdsRaw === '') {
data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : [];
}
try { try {
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data); const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
return { record }; return { record };
@@ -125,16 +107,4 @@ export const actions = {
} }
}, },
unassignChore: async (event) => {
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;
try {
const record = await hono.admin.remove(event, 'assigned-chores', famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' });
}
},
}; };
@@ -3,7 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { ChoreTemplate, AssignedChore, Member } from '$lib/types'; import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types';
let { data, form } = $props(); let { data, form } = $props();
@@ -30,18 +30,45 @@
let editTplType = $state('points') let editTplType = $state('points')
let editTplValue = $state(10) let editTplValue = $state(10)
let seasonFilter = $state('all')
let editSeasonIds = $state<string[]>([])
let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || [])) let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || []))
let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || [])) let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || []))
let assigned = $state(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || [])) let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
let seasons = $state(famStore.initialized ? famStore.seasons : (data.seasons || []))
let filteredAssigned = $derived(
seasonFilter === 'all'
? assigned
: assigned.filter(a => a.seasonIds?.includes(seasonFilter))
)
function assignedForMember(memberId: string): AssignedChore[] { function assignedForMember(memberId: string): AssignedChore[] {
return filteredAssigned.filter(a => a.memberId === memberId)
}
function assignedForMemberAll(memberId: string): AssignedChore[] {
return assigned.filter(a => a.memberId === memberId) return assigned.filter(a => a.memberId === memberId)
} }
function isGlobalChore(a: AssignedChore): boolean {
return !a.seasonIds || a.seasonIds.length === 0
}
function templateName(templateId: string): string { function templateName(templateId: string): string {
return famStore.templateMap().get(templateId)?.name || '?' return famStore.templateMap().get(templateId)?.name || '?'
} }
function seasonName(id: string): string {
return seasons.find(s => s.id === id)?.name || id
}
function seasonNames(a: AssignedChore): string {
return (a.seasonIds || []).map(id => seasonName(id)).join(', ') || 'Global'
}
function getDefault(templateId: string): { frequency: string; type: string; value: number } { function getDefault(templateId: string): { frequency: string; type: string; value: number } {
const t = famStore.templateMap().get(templateId) const t = famStore.templateMap().get(templateId)
if (!t) return { frequency: 'daily', type: 'points', value: 10 } if (!t) return { frequency: 'daily', type: 'points', value: 10 }
@@ -65,25 +92,16 @@
if (!tid) return if (!tid) return
draggedTemplateId = null draggedTemplateId = null
const s = page.data.session as any
if (!s) return
const def = getDefault(tid) const def = getDefault(tid)
const fd = new FormData() const body: Record<string, unknown> = { memberId, templateId: tid, frequency: def.frequency, type: def.type, value: def.value }
fd.set('memberId', memberId) if (seasonFilter !== 'all') body.seasonIds = [seasonFilter]
fd.set('templateId', tid) await fetch(`/api/admin/${s.famId}/assigned-chores`, {
fd.set('frequency', def.frequency)
fd.set('type', def.type)
fd.set('value', String(def.value))
const res = await fetch(`/${page.params.fam}/${page.params.username}/chores?/assignChore`, {
method: 'POST', method: 'POST',
body: fd, headers: { 'Content-Type': 'application/json', 'x-session-famid': s.famId, 'x-session-userid': s.userId },
body: JSON.stringify(body),
}) })
if (res.ok) {
const body = await res.json()
if (body.record) {
assigned = [body.record as AssignedChore, ...assigned]
famStore.applyRecord('assigned_chores', body.record, 'create')
}
}
} }
function openEdit(a: AssignedChore) { function openEdit(a: AssignedChore) {
@@ -92,6 +110,7 @@
editType = a.type editType = a.type
editValue = a.value editValue = a.value
editCustomName = a.customName || '' editCustomName = a.customName || ''
editSeasonIds = a.seasonIds ? [...a.seasonIds] : []
showEditModal = true showEditModal = true
} }
@@ -138,6 +157,7 @@
type: editType, type: editType,
value: editValue, value: editValue,
customName: editCustomName || undefined, customName: editCustomName || undefined,
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
} as AssignedChore } as AssignedChore
famStore.applyRecord('assigned_chores', assigned[idx], 'update') famStore.applyRecord('assigned_chores', assigned[idx], 'update')
} }
@@ -179,6 +199,16 @@
<p class="error">{form.error}</p> <p class="error">{form.error}</p>
{/if} {/if}
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each seasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
<CardGrid> <CardGrid>
<Card cols={3}> <Card cols={3}>
<div style="margin-bottom:0.75rem"> <div style="margin-bottom:0.75rem">
@@ -233,12 +263,28 @@
<strong>{a.customName || tName}</strong> <strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span> <span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span> <span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</span>
{/if}
</div> </div>
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div> <div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}> <div class="card-meta">
<input type="hidden" name="id" value={a.id} /> <span class="season-tags">{seasonNames(a)}</span>
<button type="submit" class="del-btn" title="Remove assignment">×</button> </div>
</form> <button
class="del-btn"
title="Remove assignment"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
const res = await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId },
});
if (res.ok) famStore.applyRecord('assigned_chores', { id: a.id }, 'delete');
}}
>×</button>
</div> </div>
{/each} {/each}
{#if assignedForMember(m.id).length === 0} {#if assignedForMember(m.id).length === 0}
@@ -333,6 +379,7 @@
<h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3> <h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3>
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}> <form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
<input name="id" type="hidden" value={editingAssignment.id} /> <input name="id" type="hidden" value={editingAssignment.id} />
<input name="seasonIds" type="hidden" value={editSeasonIds.join(',')} />
<label> <label>
Custom Name Custom Name
<input name="customName" bind:value={editCustomName} placeholder="Override template name" /> <input name="customName" bind:value={editCustomName} placeholder="Override template name" />
@@ -351,11 +398,33 @@
<option value="money">Money</option> <option value="money">Money</option>
</select> </select>
</label> </label>
<label> <label>
Value Value
<input name="value" type="number" step="any" bind:value={editValue} required /> <input name="value" type="number" step="any" bind:value={editValue} required />
</label> </label>
<div class="modal-actions"> <label>
Seasons
<div class="season-checklist">
{#each seasons as s}
<label class="check-item">
<input type="checkbox" checked={editSeasonIds.includes(s.id)} onchange={() => {
if (editSeasonIds.includes(s.id)) {
editSeasonIds = editSeasonIds.filter(x => x !== s.id);
} else {
editSeasonIds = [...editSeasonIds, s.id];
}
}} />
<span class="dot" style="background:{s.color}"></span>
{s.name}
</label>
{/each}
<label class="check-item">
<input type="checkbox" checked={editSeasonIds.length === 0} onchange={() => editSeasonIds = []} />
Global (no season)
</label>
</div>
</label>
<div class="modal-actions">
<button type="button" onclick={closeEdit}>Cancel</button> <button type="button" onclick={closeEdit}>Cancel</button>
<button type="submit">Save</button> <button type="submit">Save</button>
</div> </div>
@@ -384,6 +453,15 @@
.edit-btn:hover { color: #6366f1; } .edit-btn:hover { color: #6366f1; }
.del-btn:hover { color: #dc2626; } .del-btn:hover { color: #dc2626; }
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; } .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 { cursor: grab; border-left: 3px solid #6366f1; }
.template:active { cursor: grabbing; opacity: 0.8; } .template:active { cursor: grabbing; opacity: 0.8; }
@@ -23,12 +23,14 @@
} }
function rewardStatus(r: any): string { 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'; if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed'; 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) => { let sorted = $derived([...rewards].sort((a: any, b: any) => {
const da = a.date || a.id || ''; const da = a.date || a.id || '';
@@ -50,7 +52,7 @@
let totalOutstanding = $derived( let totalOutstanding = $derived(
rewards rewards
.filter((r: any) => isOutstanding(r)) .filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash') .filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0) .reduce((sum: number, r: any) => sum + Number(r.value), 0)
) )
@@ -81,7 +83,7 @@
<tbody> <tbody>
{#each sorted as r} {#each sorted as r}
{@const outstanding = isOutstanding(r)} {@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding}> <tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
<td class="date">{r.date?.slice(0, 10) || '—'}</td> <td class="date">{r.date?.slice(0, 10) || '—'}</td>
<td> <td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span> <span class="dot" style="background:{memberColor(r.memberId)}"></span>
@@ -90,7 +92,7 @@
<td>{r.label}</td> <td>{r.label}</td>
<td class="num">{rewardAmount(r)}</td> <td class="num">{rewardAmount(r)}</td>
<td> <td>
<span class="status-badge" class:outstanding class:claimed-status={!outstanding}> <span class="status-badge" class:outstanding class:requested-status={isRequested(r)} class:claimed-status={!outstanding && !isRequested(r)}>
{rewardStatus(r)} {rewardStatus(r)}
</span> </span>
</td> </td>
@@ -129,9 +131,11 @@
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; } .date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
tr.claimed { opacity: 0.5; } tr.claimed { opacity: 0.5; }
tr.claimed td { color: #9ca3af; } tr.claimed td { color: #9ca3af; }
tr.requested { background: #eff6ff; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; } .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 { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
.status-badge.outstanding { background: #fef3c7; color: #92400e; } .status-badge.outstanding { background: #fef3c7; color: #92400e; }
.status-badge.requested-status { background: #dbeafe; color: #1e40af; }
.status-badge.claimed-status { background: #d1fae5; color: #065f46; } .status-badge.claimed-status { background: #d1fae5; color: #065f46; }
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; } tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
</style> </style>
@@ -5,11 +5,12 @@ import type { RequestEvent } from '@sveltejs/kit';
export async function load(event: RequestEvent) { export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; 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.list(event, 'members', famId),
hono.admin.fam(event, famId), hono.admin.fam(event, famId),
hono.admin.list(event, 'seasons', famId),
]); ]);
return { members, fam }; return { members, fam, seasons };
} }
export const actions = { export const actions = {
@@ -54,4 +55,59 @@ export const actions = {
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' }; if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
return await hono.admin.updatePayday(event, famId, payday); 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' };
}
},
}; };
@@ -28,6 +28,7 @@ import { onDestroy } from 'svelte';
) )
let members = $state(famStore.initialized ? famStore.members : (data.members || [])) let members = $state(famStore.initialized ? famStore.members : (data.members || []))
let deletingSeason = $state<any>(null)
function copy(url: string) { function copy(url: string) {
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
@@ -140,12 +141,73 @@ import { onDestroy } from 'svelte';
<p class="hint">They will set up their own password on first login.</p> <p class="hint">They will set up their own password on first login.</p>
</Card> </Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="inline">
<input name="name" placeholder="Season name" required />
<input name="color" type="color" value="#6366f1" class="color-input" />
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each data.seasons as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => deletingSeason = s}>Remove</Button>
</li>
{/each}
</ul>
</Card>
<!-- Delete Season Modal -->
{#if deletingSeason}
<div class="overlay" onclick={() => deletingSeason = null} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Delete "{deletingSeason.name}"?</h3>
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
<form method="POST" action="?/deleteSeason" use:enhance={() => { return async ({ result }) => {
if (result.type === 'success') {
const r = result.data;
if (r?.deletedChoreIds) {
r.deletedChoreIds.forEach((id: string) => famStore.applyRecord('assigned_chores', { id }, 'delete'));
}
famStore.applyRecord('seasons', { id: deletingSeason.id }, 'delete');
deletingSeason = null;
}
}}}>
<input type="hidden" name="id" value={deletingSeason.id} />
<div class="modal-actions">
<button type="button" onclick={() => deletingSeason = null}>Cancel</button>
<button type="submit" class="danger">Delete Season</button>
</div>
</form>
</div>
</div>
{/if}
<Card title="Data" cols={1}> <Card title="Data" cols={1}>
<div class="actions"> <div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button> <Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button> <Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div> </div>
</Card> </Card>
{#if data.fam?.featureFlags?.debugMode}
<Card title="Debug Tools" cols={1} accent="#f59e0b">
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
<div class="actions">
<form method="POST" action="?/completeWeek" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Week completed!'); await update(); }; }}>
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
</form>
<form method="POST" action="?/generateData" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Test data generated!'); await update(); }; }}>
<input type="hidden" name="days" value="7" />
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
</form>
</div>
</Card>
{/if}
</CardGrid> </CardGrid>
<style> <style>
@@ -168,4 +230,13 @@ import { onDestroy } from 'svelte';
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.5; cursor: not-allowed; }
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; } .danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
.inline { display: inline; margin: 0; } .inline { display: inline; margin: 0; }
.color-input { width: 40px; height: 34px; padding: 0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
.modal h3 { margin: 0 0 0.75rem; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.modal-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.modal-actions button[type="button"] { background: #e5e7eb; color: #374151; }
.modal-actions button[type="submit"] { background: #dc2626; color: white; }
.warning { color: #dc2626; font-size: 0.9rem; background: #fef2f2; padding: 0.6rem; border-radius: 6px; margin-bottom: 0.5rem; }
</style> </style>
+77 -19
View File
@@ -1,23 +1,81 @@
import { pbAdmin } from '$lib/server/pb-admin'; 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() { export const load: PageServerLoad = async ({ cookies }) => {
const fams = await pbAdmin.getList('fams'); const session = cookies.get('platform_session');
const famsWithStats = await Promise.all(fams.map(async (fam: any) => { if (!session) {
const [members, rewards] = await Promise.all([ return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
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,
};
}));
const totalFams = fams.length; try {
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0); const fams = await pbAdmin.getList('fams');
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0); 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' });
}
},
};
+218 -29
View File
@@ -1,37 +1,226 @@
<script lang="ts"> <script lang="ts">
let { data } = $props(); import { enhance } from '$app/forms';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
let { data, form } = $props();
</script> </script>
<h1>Super Admin</h1> {#if !data.authenticated}
<div class="login-container">
<div class="login-card">
<h1>Platform Admin</h1>
<p class="subtitle">Sign in to access the admin dashboard</p>
<div class="stats"> {#if form?.error}
<div class="card"><strong>{data.totalFams}</strong> Families</div> <p class="error">{form.error}</p>
<div class="card"><strong>{data.totalMembers}</strong> Members</div> {/if}
<div class="card"><strong>{data.totalRewards}</strong> Rewards</div>
</div>
<h2>Families</h2> <form method="POST" action="?/login" use:enhance>
<table> <div class="form-group">
<thead><tr><th>Name</th><th>Slug</th><th>Members</th><th>Rewards</th><th>Invite Code</th></tr></thead> <label for="email">Email</label>
<tbody> <input type="email" id="email" name="email" required />
{#each data.fams as fam} </div>
<tr> <div class="form-group">
<td><a href="/{fam.slug}/admin">{fam.name}</a></td> <label for="password">Password</label>
<td>{fam.slug}</td> <input type="password" id="password" name="password" required />
<td>{fam.memberCount}</td> </div>
<td>{fam.claimedRewards}/{fam.totalRewards}</td> <Button type="submit" variant="primary" size="lg">Sign In</Button>
<td><code>{fam.inviteCode}</code></td> </form>
</tr> </div>
{/each} </div>
</tbody> {:else}
</table> <ViewHeader title="Platform Admin" subtitle="Manage families and platform settings" />
<CardGrid>
<Card cols={1} title="Overview" accent="#6366f1">
<div class="stats">
<div class="stat">
<span class="stat-value">{data.totalFams}</span>
<span class="stat-label">Families</span>
</div>
<div class="stat">
<span class="stat-value">{data.totalMembers}</span>
<span class="stat-label">Members</span>
</div>
<div class="stat">
<span class="stat-value">{data.totalRewards}</span>
<span class="stat-label">Rewards</span>
</div>
</div>
</Card>
<Card cols={2} title="Families" accent="#059669">
<table class="fam-table">
<thead>
<tr>
<th>Name</th>
<th>Parent</th>
<th>Members</th>
<th>Claims</th>
<th>Debug</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each data.fams as fam}
<tr>
<td>
<a href="/{fam.slug}/admin">{fam.name}</a>
<span class="slug">/{fam.slug}</span>
</td>
<td>{fam.parentEmail || '—'}</td>
<td>{fam.memberCount}</td>
<td>
{#if fam.requestedRewards > 0}
<span class="badge pending">{fam.requestedRewards} pending</span>
{:else}
<span class="badge none">None</span>
{/if}
</td>
<td>
<form method="POST" action="?/toggleFeatureFlag" use:enhance>
<input type="hidden" name="famId" value={fam.id} />
<input type="hidden" name="flag" value="debugMode" />
<button
type="submit"
class="toggle-btn"
class:active={fam.featureFlags?.debugMode}
>
{fam.featureFlags?.debugMode ? 'ON' : 'OFF'}
</button>
</form>
</td>
<td>
<a href="/{fam.slug}/admin" class="link">Dashboard</a>
<a href="/{fam.slug}" class="link">View</a>
</td>
</tr>
{/each}
</tbody>
</table>
</Card>
</CardGrid>
<form method="POST" action="?/logout" class="logout-form">
<Button type="submit" variant="ghost" size="sm">Sign Out</Button>
</form>
{/if}
<style> <style>
.stats { display: flex; gap: 1rem; margin: 1rem 0; } .login-container {
.card { padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; flex: 1; text-align: center; } display: flex;
.card strong { font-size: 1.5rem; display: block; } justify-content: center;
table { width: 100%; border-collapse: collapse; margin: 1rem 0; } align-items: center;
th, td { padding: 0.5rem; border: 1px solid #e5e7eb; text-align: left; } min-height: 100vh;
th { background: #f9fafb; font-weight: 600; } background: #f3f4f6;
code { background: #eee; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; } }
.login-card {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-card h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.subtitle {
color: #6b7280;
margin: 0 0 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
}
.error {
color: #dc2626;
background: #fef2f2;
padding: 0.5rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.stats {
display: flex;
gap: 2rem;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 0.85rem;
color: #6b7280;
}
.fam-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.fam-table th, .fam-table td {
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
text-align: left;
}
.fam-table th {
font-weight: 600;
color: #374151;
}
.slug {
color: #9ca3af;
font-size: 0.8rem;
margin-left: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.badge.pending { background: #fef3c7; color: #92400e; }
.badge.none { background: #f3f4f6; color: #6b7280; }
.toggle-btn {
background: #e5e7eb;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
}
.toggle-btn.active {
background: #059669;
color: white;
}
.link {
color: #2563eb;
text-decoration: none;
margin-right: 0.5rem;
font-size: 0.85rem;
}
.link:hover { text-decoration: underline; }
.logout-form {
position: fixed;
top: 1rem;
right: 1rem;
}
</style> </style>
+22 -1
View File
@@ -131,6 +131,7 @@ async function main() {
text("inviteCode"), text("inviteCode"),
text("stripeCustomerId"), text("stripeCustomerId"),
jsonField("featureFlags"), jsonField("featureFlags"),
jsonField("seasons"),
], ],
}); });
@@ -263,8 +264,9 @@ async function main() {
text("label", true), text("label", true),
number("value", true), number("value", true),
select("rewardType", ["cash", "prize", "points"], true), select("rewardType", ["cash", "prize", "points"], true),
bool("claimed"), select("status", ["unclaimed", "requested", "claimed"], true),
date("claimedAt"), date("claimedAt"),
date("requestedAt"),
text("date"), text("date"),
], ],
}); });
@@ -285,6 +287,25 @@ async function main() {
select("type", ["points", "money"], true), select("type", ["points", "money"], true),
number("value", true), number("value", true),
text("customName"), 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"),
], ],
}); });
+279 -25
View File
@@ -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) => { app.delete("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => {
try { 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); await pb.delete("assigned_chores", id);
return c.json({ ok: true }); return c.json({ ok: true });
} catch (err) { return handleError(c, err); } } 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 ──────────────────────── // ── Admin: Regenerate invite code ────────────────────────
app.get("/api/admin/:famId/fam", requireAdmin, async (c) => { 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[] = []; let rewardPointsList: any[] = [];
try { 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; rewardPointsList = pointRewards.items;
} catch {} // schema may not have rewardType/claimed yet } catch {} // schema may not have rewardType/status yet
const assignedList = assigned.items; const assignedList = assigned.items;
const completionsList = completions.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); return sum + (chore?.type === "money" ? Number(chore.value) : 0);
}, 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 { return {
memberId: m.id, memberId: m.id,
memberName: m.name, memberName: m.name,
memberColor: m.color, memberColor: m.color,
pointsEarned: weekPoints + bonusPoints, pointsEarned: weekPoints + bonusPoints,
moneyEarned: weekMoney, moneyEarned: weekMoney + bonusMoney,
choresCompleted: memberCompletions.length, choresCompleted: memberCompletions.length,
totalChores: memberAssignments.length, totalChores: memberAssignments.length,
dayPoints, dayPoints,
@@ -590,10 +638,10 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
current: teamCurrent, current: teamCurrent,
criteriaValue: cfg.criteriaValue || 0, criteriaValue: cfg.criteriaValue || 0,
reward: teamReward reward: teamReward
? { id: teamReward.id, claimed: teamReward.claimed } ? { id: teamReward.id, status: teamReward.status }
: null, : null,
state: teamReward state: teamReward
? (teamReward.claimed ? "claimed" : "unclaimed") ? teamReward.status
: "pending", : "pending",
achieved: teamReward ? true : false, achieved: teamReward ? true : false,
}); });
@@ -626,10 +674,10 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
current, current,
criteriaValue: cfg.criteriaValue || 0, criteriaValue: cfg.criteriaValue || 0,
reward: memberReward reward: memberReward
? { id: memberReward.id, claimed: memberReward.claimed } ? { id: memberReward.id, status: memberReward.status }
: null, : null,
state: memberReward state: memberReward
? (memberReward.claimed ? "claimed" : "unclaimed") ? memberReward.status
: "pending", : "pending",
achieved: memberReward ? true : false, achieved: memberReward ? true : false,
}); });
@@ -730,7 +778,7 @@ async function evaluateFam(famId: string): Promise<void> {
famId, memberId: m.id, bonusConfigId: cfg.id, famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0, label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
claimed: cfg.rewardType === "points", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), date: now.slice(0, 10),
}); });
@@ -755,17 +803,17 @@ async function evaluateFam(famId: string): Promise<void> {
const winner = eligible.sort((a, b) => b.current - a.current)[0]; const winner = eligible.sort((a, b) => b.current - a.current)[0];
if (winner && !existingRewards.length) { if (winner && !existingRewards.length) {
const label = cfg.rewardType === "cash" const label = cfg.rewardType === "cash"
? `${cfg.name} £${(Number(cfg.rewardValue) / 100).toFixed(2)}` ? `${cfg.name} £${(Number(cfg.rewardValue) / 100).toFixed(2)}`
: `${cfg.name} ${cfg.rewardValue}`; : `${cfg.name} ${cfg.rewardValue}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
await pb.create("rewards", { await pb.create("rewards", {
famId, memberId: winner.memberId, bonusConfigId: cfg.id, famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0, label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
claimed: cfg.rewardType === "points", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), 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 // Check occurrence limits per target member
for (const m of targetMembers) { for (const m of targetMembers) {
const memberRewards = existingRewards.items.filter((r: any) => r.memberId === m.id); 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); return c.json({ error: `Already issued and pending for ${m.name}` }, 400);
} }
if (cfg.occurrence === "recurring" && cfg.period) { 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, famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0, label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
claimed: cfg.rewardType === "points", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), 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); } } 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) ────────────────────── // ── Member: Get chores (for kanban) ──────────────────────
app.post("/api/members/my-chores", requireDeviceToken, async (c) => { 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 { try {
const { id } = c.req.param(); const { id } = c.req.param();
const now = new Date().toISOString(); 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); return c.json(record);
} catch (err) { return handleError(c, err); } } 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 ──────────────────────── // ── Admin: Revoke a completion ────────────────────────
app.post("/api/admin/:famId/completions/:id/revoke", requireAdmin, async (c) => { 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 famId = c.get("famId");
const memberId = c.get("memberId"); const memberId = c.get("memberId");
const now = new Date().toISOString(); 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 // Create notification for admin
const members = await pb.getList("members", `famId = '${famId}' && id = '${memberId}'`); const members = await pb.getList("members", `famId = '${famId}' && id = '${memberId}'`);
const memberName = members.items?.[0]?.name || "Unknown"; 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 }); await pb.create("notifications", { famId, memberId, message: msg, read: false });
return c.json(record); return c.json(record);
} catch (err) { return handleError(c, err); } } 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 ────────────────────────────── // ── Member: Notifications ──────────────────────────────
app.get("/api/members/notifications", requireDeviceToken, async (c) => { 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); } } 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 ───────────────────────────────────────── // ── Start server ─────────────────────────────────────────
const port = parseInt(process.env.PROXY_PORT || "3456", 10); const port = parseInt(process.env.PROXY_PORT || "3456", 10);
+194
View File
@@ -553,5 +553,199 @@ export async function migrate(): Promise<void> {
} }
} }
// ── 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"); console.log("[migrate] Done");
} }
+5 -5
View File
@@ -56,14 +56,14 @@ export const pb = {
async delete(collection: string, id: string) { async delete(collection: string, id: string) {
const res = await request("DELETE", `/api/collections/${collection}/records/${id}`); 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 = "") { async getList(collection: string, filter = "") {
const params = new URLSearchParams(); let path = `/api/collections/${collection}/records?perPage=1000`;
if (filter) params.set("filter", filter); if (filter) path += `&filter=${encodeURIComponent(filter)}`;
params.set("perPage", "200"); const res = await request("GET", path);
const res = await request("GET", `/api/collections/${collection}/records?${params}`);
const json = await res.json(); const json = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`); if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`);
return json; return json;