Compare commits
30 Commits
29d50985df
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de3c8ca62 | |||
| d14a154dcb | |||
| 89ab955efc | |||
| edc8568cc4 | |||
| c070cc8053 | |||
| 8388868bee | |||
| 2a3f3836c0 | |||
| 6288a3d3f2 | |||
| adcb400f41 | |||
| a710e22f35 | |||
| 31da825e93 | |||
| fa1bf1e09d | |||
| 66ed9750e9 | |||
| 0fed19461e | |||
| 67b5eff06b | |||
| a8c674f8b3 | |||
| 8e4eef06f5 | |||
| 1609e11c48 | |||
| 18511b6546 | |||
| 9e6d835061 | |||
| 0613fc4f46 | |||
| 6b6d2df945 | |||
| 6d05721629 | |||
| 3a6ba556e6 | |||
| 9566506d30 | |||
| 812f6a4f88 | |||
| 234b2cd8ee | |||
| 78f456bf77 | |||
| 833a34049e | |||
| 39e432e52a |
@@ -7,6 +7,7 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
setChildSessionCookie,
|
setChildSessionCookie,
|
||||||
|
setActiveChild,
|
||||||
clearActiveChild,
|
clearActiveChild,
|
||||||
PLATFORM_SESSION_COOKIE,
|
PLATFORM_SESSION_COOKIE,
|
||||||
clearPlatformSession
|
clearPlatformSession
|
||||||
@@ -29,6 +30,7 @@ function sessionFrom(record: any, freshToken: string) {
|
|||||||
pattern: record.pattern || '',
|
pattern: record.pattern || '',
|
||||||
themeSize: record.themeSize || '',
|
themeSize: record.themeSize || '',
|
||||||
themeOpacity: record.themeOpacity || '',
|
themeOpacity: record.themeOpacity || '',
|
||||||
|
partyEmoji: record.partyEmoji || '',
|
||||||
token: freshToken
|
token: freshToken
|
||||||
} satisfies SessionUser & { token: string };
|
} satisfies SessionUser & { token: string };
|
||||||
}
|
}
|
||||||
@@ -80,11 +82,22 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (freshToken !== childToken) {
|
if (freshToken !== childToken) {
|
||||||
setChildSessionCookie(event.cookies, activeId, freshToken);
|
setChildSessionCookie(event.cookies, activeId, freshToken);
|
||||||
}
|
}
|
||||||
|
// Sliding expiry: pb_active has the same 5-day maxAge as the
|
||||||
|
// token cookies but was previously never re-set, so it aged
|
||||||
|
// out 5 days after join/switch despite daily use. Re-set it
|
||||||
|
// on every authenticated request to keep it alive.
|
||||||
|
setActiveChild(event.cookies, activeId);
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
} catch {
|
console.error(
|
||||||
|
`[diag] hooks child wrong-role activeId=${activeId} role=${record.role} path=${event.url.pathname}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
// Expired/revoked/malformed — leave the cookie; the picker switch
|
// Expired/revoked/malformed — leave the cookie; the picker switch
|
||||||
// re-mints it server-side. Fall through to the single session.
|
// re-mints it server-side. Fall through to the single session.
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks child authRefresh-fail activeId=${activeId} path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,8 +115,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (freshToken !== token) {
|
if (freshToken !== token) {
|
||||||
setSessionCookie(event.cookies, freshToken);
|
setSessionCookie(event.cookies, freshToken);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Expired, malformed, or revoked — drop it and treat as logged out.
|
// Expired, malformed, or revoked — drop it and treat as logged out.
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks single authRefresh-fail path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
clearSessionCookie(event.cookies);
|
clearSessionCookie(event.cookies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ async function memberFetch<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const memberApi = {
|
export const memberApi = {
|
||||||
async toggleCompletion(famId: string, assignedChoreId: string, date: string) {
|
async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
|
||||||
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date });
|
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
|
||||||
|
},
|
||||||
|
async createTodo(name: string) {
|
||||||
|
return memberFetch<{ record: unknown }>('POST', '/api/todos', { name });
|
||||||
},
|
},
|
||||||
async claimReward(famId: string, rewardId: string) {
|
async claimReward(famId: string, rewardId: string) {
|
||||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
successLabel = 'Saved ✓',
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'sm',
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
successLabel?: string;
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let saved = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
// Parent forms call this (via bind:this) from their use:enhance callback
|
||||||
|
// on result.type === 'success'. Green for 4s, then back to normal.
|
||||||
|
export function flash() {
|
||||||
|
saved = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => (saved = false), 4000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button type="submit" {size} variant={saved ? 'success' : variant} {...rest}>
|
||||||
|
{saved ? successLabel : label}
|
||||||
|
</Button>
|
||||||
@@ -6,6 +6,7 @@ export { default as ViewHeader } from './ViewHeader.svelte';
|
|||||||
export { default as Card } from './Card.svelte';
|
export { default as Card } from './Card.svelte';
|
||||||
export { default as CardGrid } from './CardGrid.svelte';
|
export { default as CardGrid } from './CardGrid.svelte';
|
||||||
export { default as Button } from './Button.svelte';
|
export { default as Button } from './Button.svelte';
|
||||||
|
export { default as SaveButton } from './SaveButton.svelte';
|
||||||
export { default as Accordion } from './Accordion.svelte';
|
export { default as Accordion } from './Accordion.svelte';
|
||||||
export { default as AccordionItem } from './AccordionItem.svelte';
|
export { default as AccordionItem } from './AccordionItem.svelte';
|
||||||
export { default as Chat } from './Chat.svelte';
|
export { default as Chat } from './Chat.svelte';
|
||||||
|
|||||||
@@ -479,6 +479,9 @@ async function ensureUserFields(): Promise<void> {
|
|||||||
if (!has('passwordResetExpiry')) {
|
if (!has('passwordResetExpiry')) {
|
||||||
needed.push({ name: 'passwordResetExpiry', type: 'text', required: false });
|
needed.push({ name: 'passwordResetExpiry', type: 'text', required: false });
|
||||||
}
|
}
|
||||||
|
if (!has('partyEmoji')) {
|
||||||
|
needed.push({ name: 'partyEmoji', type: 'text', required: false });
|
||||||
|
}
|
||||||
if (needed.length) {
|
if (needed.length) {
|
||||||
await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] });
|
await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] });
|
||||||
}
|
}
|
||||||
@@ -526,7 +529,7 @@ export async function migrate(): Promise<void> {
|
|||||||
await ensureBonusFields();
|
await ensureBonusFields();
|
||||||
await ensureTemplateFields();
|
await ensureTemplateFields();
|
||||||
await ensureAssignedChoreFields();
|
await ensureAssignedChoreFields();
|
||||||
await ensureAccessCodes();
|
await ensureFamReadRules(); await ensureAccessCodes();
|
||||||
await ensurePlatform();
|
await ensurePlatform();
|
||||||
// Superuser-only collections also land on EXISTING installs (ensureSchema's
|
// Superuser-only collections also land on EXISTING installs (ensureSchema's
|
||||||
// early return skips them). Like ensureOtp before it, ensurePins no-ops when
|
// early return skips them). Like ensureOtp before it, ensurePins no-ops when
|
||||||
@@ -540,6 +543,23 @@ export async function migrate(): Promise<void> {
|
|||||||
console.log('[migrate] Done');
|
console.log('[migrate] Done');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Children toggle chores with their own token, so fam-scoped collections need
|
||||||
|
// fam read rules on EXISTING installs (fresh installs get them via
|
||||||
|
// SCHEMA_PLAN). Without these, the chore lookup returns empty and the
|
||||||
|
// completions create fails relation validation ("resource cannot be found").
|
||||||
|
// Idempotent.
|
||||||
|
async function ensureFamReadRules(): Promise<void> {
|
||||||
|
const readRule = 'famId = @request.auth.famId';
|
||||||
|
for (const name of ['assigned_chores', 'completions', 'rewards']) {
|
||||||
|
const col = await getCollection(name);
|
||||||
|
if (!col) continue;
|
||||||
|
if (col.listRule !== readRule || col.viewRule !== readRule) {
|
||||||
|
await updateCollection(col.id, { ...col, listRule: readRule, viewRule: readRule });
|
||||||
|
console.log(`[migrate] Fixed read rules on ${name}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add colour / icon / description to `assigned_chores` on existing installs so
|
// Add colour / icon / description to `assigned_chores` on existing installs so
|
||||||
// family-admins can override a template's look per assignment. Idempotent.
|
// family-admins can override a template's look per assignment. Idempotent.
|
||||||
async function ensureAssignedChoreFields(): Promise<void> {
|
async function ensureAssignedChoreFields(): Promise<void> {
|
||||||
@@ -1166,11 +1186,12 @@ async function seedDemoCompletions(): Promise<void> {
|
|||||||
const startDate = addDays(todayDate, -20);
|
const startDate = addDays(todayDate, -20);
|
||||||
|
|
||||||
// Track per-member-per-week totals for weekly_history
|
// Track per-member-per-week totals for weekly_history
|
||||||
type WeekKey = string; // "YYYY-MM-DD" (Monday)
|
type WeekKey = string; // "YYYY-MM-DD" (week start: day after payday)
|
||||||
const weekTotals: Record<
|
const weekTotals: Record<
|
||||||
string,
|
string,
|
||||||
Record<string, { points: number; money: number; count: number }>
|
Record<string, { points: number; money: number; count: number }>
|
||||||
> = {};
|
> = {};
|
||||||
|
const demoPayday = fam.payday ?? 1;
|
||||||
|
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
|
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
|
||||||
@@ -1180,10 +1201,14 @@ async function seedDemoCompletions(): Promise<void> {
|
|||||||
const ds = dateStr(day);
|
const ds = dateStr(day);
|
||||||
const dow = day.getUTCDay(); // 0=Sun
|
const dow = day.getUTCDay(); // 0=Sun
|
||||||
|
|
||||||
// Compute week start (Monday) for weekly_history tracking
|
// Compute week start (payday-anchored: weeks close on payday, so
|
||||||
const weekMonday = new Date(day);
|
// the bucket opens the day after — same rule as weekStart()).
|
||||||
weekMonday.setUTCDate(weekMonday.getUTCDate() - ((weekMonday.getUTCDay() + 6) % 7));
|
const dowNum = day.getUTCDay(); // 0=Sun
|
||||||
const ws = dateStr(weekMonday);
|
const sincePayday = (((dowNum - demoPayday) % 7) + 7) % 7;
|
||||||
|
const back = sincePayday === 0 ? 6 : sincePayday - 1;
|
||||||
|
const wsDay = new Date(day);
|
||||||
|
wsDay.setUTCDate(wsDay.getUTCDate() - back);
|
||||||
|
const ws = dateStr(wsDay);
|
||||||
|
|
||||||
if (!weekTotals[member.id]) weekTotals[member.id] = {};
|
if (!weekTotals[member.id]) weekTotals[member.id] = {};
|
||||||
if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
|
if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
|
||||||
@@ -1196,8 +1221,9 @@ async function seedDemoCompletions(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const chore of memberChores) {
|
for (const chore of memberChores) {
|
||||||
// Weekly chores: only complete on Monday (dow=1)
|
// Weekly chores: only complete on the week's first day
|
||||||
if (chore.frequency === 'weekly' && dow !== 1) continue;
|
// (day after payday).
|
||||||
|
if (chore.frequency === 'weekly' && dow !== (demoPayday + 1) % 7) continue;
|
||||||
|
|
||||||
const prob = dayProfile[chore.customName] || dayProfile.default;
|
const prob = dayProfile[chore.customName] || dayProfile.default;
|
||||||
const rng = demoRng(
|
const rng = demoRng(
|
||||||
|
|||||||
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
|
|||||||
// Superuser PB client (memoized). Reserved for server-only privileged
|
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||||
// operations that must bypass collection rules: creating child users, minting
|
// operations that must bypass collection rules: creating child users, minting
|
||||||
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
||||||
|
//
|
||||||
|
// NOTE: the auth token expires (prod PB showed ~36h). A stale memoized client
|
||||||
|
// goes out UNAUTHENTICATED, and PocketBase answers unauthenticated getOne()
|
||||||
|
// calls with 404 "resource wasn't found" — which the toggle path misreads as
|
||||||
|
// CHORE_GONE for every chore. So always re-auth when the stored token is no
|
||||||
|
// longer valid instead of reusing a dead client.
|
||||||
let superClient: PocketBase | null = null;
|
let superClient: PocketBase | null = null;
|
||||||
export async function createSuperClient() {
|
export async function createSuperClient() {
|
||||||
if (superClient) return superClient;
|
if (superClient?.authStore.isValid) return superClient;
|
||||||
|
superClient = null;
|
||||||
const pb = new PocketBase(PB_ENDPOINT);
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
pb.autoCancellation(false);
|
pb.autoCancellation(false);
|
||||||
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
||||||
@@ -40,29 +47,43 @@ export async function createSuperClient() {
|
|||||||
return pb;
|
return pb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run a superuser op, retrying once with a freshly-authenticated client when
|
||||||
|
// the first attempt hits an auth-shaped failure. A locally-valid token can
|
||||||
|
// still be dead server-side (restart/revoked secret rotation), and PB
|
||||||
|
// surfaces that as 401/403 — or as 404 when a viewRule then denies access.
|
||||||
|
async function withSuperRetry<T>(op: (pb: PocketBase) => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await op(await createSuperClient());
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status;
|
||||||
|
if (status === 401 || status === 403 || status === 404) {
|
||||||
|
superClient = null;
|
||||||
|
return await op(await createSuperClient());
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
||||||
// server PB access (authenticated user + superuser) lives in this one module.
|
// server PB access (authenticated user + superuser) lives in this one module.
|
||||||
export const pbAdmin = {
|
export const pbAdmin = {
|
||||||
async getList(collection: string, filter = '') {
|
async getList(collection: string, filter = '') {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => {
|
||||||
const options: { filter?: string } = {};
|
const options: { filter?: string } = {};
|
||||||
if (filter) options.filter = filter;
|
if (filter) options.filter = filter;
|
||||||
return pb.collection(collection).getFullList(options);
|
return pb.collection(collection).getFullList(options);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async getOne(collection: string, id: string) {
|
async getOne(collection: string, id: string) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).getOne(id));
|
||||||
return pb.collection(collection).getOne(id);
|
|
||||||
},
|
},
|
||||||
async create(collection: string, data: Record<string, unknown>) {
|
async create(collection: string, data: Record<string, unknown>) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).create(data));
|
||||||
return pb.collection(collection).create(data);
|
|
||||||
},
|
},
|
||||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).update(id, data));
|
||||||
return pb.collection(collection).update(id, data);
|
|
||||||
},
|
},
|
||||||
async remove(collection: string, id: string) {
|
async remove(collection: string, id: string) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).delete(id));
|
||||||
return pb.collection(collection).delete(id);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
periodEnd,
|
periodEnd,
|
||||||
nextPaydayAfter,
|
nextPaydayAfter,
|
||||||
weekStart,
|
weekStart,
|
||||||
|
weekdayInTz,
|
||||||
bonusWindow,
|
bonusWindow,
|
||||||
completionInWindow
|
completionInWindow
|
||||||
} from '@shared/timezone';
|
} from '@shared/timezone';
|
||||||
@@ -47,7 +48,13 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
|
|||||||
const now = todayInTz(resolveServerTz(tz));
|
const now = todayInTz(resolveServerTz(tz));
|
||||||
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
|
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
|
||||||
const end = periodEnd(cfg.period, start);
|
const end = periodEnd(cfg.period, start);
|
||||||
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) };
|
// Weeks close on payday, so a weekly window's end IS the settle date —
|
||||||
|
// nextPaydayAfter(end) would skip a whole extra week.
|
||||||
|
const settle =
|
||||||
|
weekdayInTz(new Date(end + 'T12:00:00Z'), tz) === payday
|
||||||
|
? end
|
||||||
|
: nextPaydayAfter(end, payday, tz);
|
||||||
|
return { claimable: 'payday', settleDate: settle };
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetChoreFor(cfg: any, memberId: string): string | undefined {
|
function targetChoreFor(cfg: any, memberId: string): string | undefined {
|
||||||
@@ -99,6 +106,15 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
||||||
|
|
||||||
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||||
|
// Uniform rule, one reward row per config per member per window:
|
||||||
|
// achieved + no window reward → create; unachieved → delete the window
|
||||||
|
// reward unless it is claimed, requested, or payday-gated (those persist
|
||||||
|
// by design until the parent issues them). Only rewards dated inside the
|
||||||
|
// CURRENT window count as "already rewarded" — last week's row must never
|
||||||
|
// block this week's payout.
|
||||||
|
const windowRewards = existingRewards.filter((r: any) => completionInWindow(r, win));
|
||||||
|
const removable = (r: any) =>
|
||||||
|
r.status !== 'claimed' && r.status !== 'requested' && r.claimable !== 'payday';
|
||||||
let createdReward = false;
|
let createdReward = false;
|
||||||
|
|
||||||
const rewardData = (memberId: string) => {
|
const rewardData = (memberId: string) => {
|
||||||
@@ -121,133 +137,122 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cfg.target === 'individual') {
|
// Completions counting toward one member (target-chore scoped when set).
|
||||||
const targetMembers = cfg.memberId
|
const completionsFor = (memberId: string) => {
|
||||||
? allMembers.filter((m: any) => m.id === cfg.memberId)
|
const memberTarget = targetChoreFor(cfg, memberId);
|
||||||
: allMembers;
|
let list = periodCompletions.filter((c: any) => c.memberId === memberId);
|
||||||
for (const m of targetMembers) {
|
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
|
||||||
const memberTarget = targetChoreFor(cfg, m.id);
|
// Collaborative configs only honour the shared targetChoreId.
|
||||||
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
if (cfg.target === 'collaborative' && cfg.targetChoreId)
|
||||||
if (memberTarget)
|
list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
||||||
memberCompletions = memberCompletions.filter(
|
return list;
|
||||||
(c: any) => c.assignedChoreId === memberTarget
|
};
|
||||||
);
|
// Score for one member: percent of due chores, points value, or raw count.
|
||||||
let current = 0;
|
const scoreFor = (memberId: string): number => {
|
||||||
|
const list = completionsFor(memberId);
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
if (cfg.thresholdType === 'percent') {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const due = dueByMember[m.id] || 0;
|
const due = dueByMember[memberId] || 0;
|
||||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
return due > 0 ? Math.round((list.length / due) * 100) : 0;
|
||||||
} else {
|
}
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
return list.reduce((sum: number, c: any) => {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
} else if (cfg.type === 'count') {
|
if (cfg.type === 'count') return list.length;
|
||||||
current = memberCompletions.length;
|
return 0;
|
||||||
}
|
};
|
||||||
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
||||||
|
// Reconcile one member's window reward against their achieved state.
|
||||||
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
|
// Returns true when a live window reward exists afterwards.
|
||||||
|
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
|
||||||
if (memberReward && !achieved) {
|
const live = windowRewards.filter((r: any) => r.memberId === memberId);
|
||||||
if (memberReward.status !== 'claimed') {
|
if (!achieved) {
|
||||||
|
for (const r of live) {
|
||||||
|
if (!removable(r)) continue;
|
||||||
try {
|
try {
|
||||||
await pb.collection('rewards').delete(memberReward.id);
|
await pb.collection('rewards').delete(r.id);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
windowRewards.splice(windowRewards.indexOf(r), 1);
|
||||||
}
|
}
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (live.length > 0) return true;
|
||||||
if (memberReward) continue;
|
await pb.collection('rewards').create(rewardData(memberId));
|
||||||
|
|
||||||
if (achieved) {
|
|
||||||
await pb.collection('rewards').create(rewardData(m.id));
|
|
||||||
createdReward = true;
|
createdReward = true;
|
||||||
}
|
return true;
|
||||||
}
|
};
|
||||||
} else if (cfg.target === 'collaborative') {
|
// Drop stale prior-window placeholders (unclaimed, non-gated) so they
|
||||||
const allMemberIds = allMembers.map((m: any) => m.id);
|
// can't pile up unseen; gated/requested/claimed rows persist by design.
|
||||||
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
const purgeStale = async (memberId: string) => {
|
||||||
if (cfg.targetChoreId)
|
const stale = existingRewards.filter(
|
||||||
teamCompletions = teamCompletions.filter(
|
(r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
|
||||||
(c: any) => c.assignedChoreId === cfg.targetChoreId
|
|
||||||
);
|
);
|
||||||
let total = 0;
|
for (const r of stale) {
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
total = Math.round((teamCompletions.length / totalDue) * 100);
|
|
||||||
} else {
|
|
||||||
total = teamCompletions.reduce((sum: number, c: any) => {
|
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
|
||||||
total = teamCompletions.length;
|
|
||||||
}
|
|
||||||
const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
|
|
||||||
|
|
||||||
if (!achieved && existingRewards.length > 0) {
|
|
||||||
for (const r of existingRewards) {
|
|
||||||
if (r.status !== 'claimed') {
|
|
||||||
try {
|
try {
|
||||||
await pb.collection('rewards').delete(r.id);
|
await pb.collection('rewards').delete(r.id);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (achieved && existingRewards.length === 0) {
|
if (cfg.target === 'competitive') {
|
||||||
for (const m of allMembers) {
|
// Winner-takes-all: a single window reward held by the top scorer.
|
||||||
await pb.collection('rewards').create(rewardData(m.id));
|
const scored = allMembers.map((m: any) => ({ memberId: m.id, current: scoreFor(m.id) }));
|
||||||
|
const qualified = scored.filter((s: any) => met(s.current));
|
||||||
|
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
||||||
|
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
||||||
|
const holder = windowRewards[0];
|
||||||
|
if (!winner) {
|
||||||
|
if (holder && removable(holder)) {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(holder.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else if (!holder) {
|
||||||
|
await purgeStale(winner.memberId);
|
||||||
|
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||||
|
createdReward = true;
|
||||||
|
} else if (holder.memberId !== winner.memberId) {
|
||||||
|
if (removable(holder)) {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(holder.id);
|
||||||
|
} catch {}
|
||||||
|
await purgeStale(winner.memberId);
|
||||||
|
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||||
createdReward = true;
|
createdReward = true;
|
||||||
}
|
}
|
||||||
|
// A locked (claimed/requested/gated) holder keeps the crown until issued.
|
||||||
}
|
}
|
||||||
} else if (cfg.target === 'competitive') {
|
|
||||||
const scored = allMembers.map((m: any) => {
|
|
||||||
const memberTarget = targetChoreFor(cfg, m.id);
|
|
||||||
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
|
||||||
if (memberTarget)
|
|
||||||
memberCompletions = memberCompletions.filter(
|
|
||||||
(c: any) => c.assignedChoreId === memberTarget
|
|
||||||
);
|
|
||||||
let current = 0;
|
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
const due = dueByMember[m.id] || 0;
|
|
||||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
|
||||||
} else {
|
} else {
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
// Individual: each member against their own score. Collaborative: the
|
||||||
|
// whole team against the team total, rewarded per member.
|
||||||
|
let teamTotal = 0;
|
||||||
|
if (cfg.target === 'collaborative') {
|
||||||
|
const ids = new Set(allMembers.map((m: any) => m.id));
|
||||||
|
let team = periodCompletions.filter((c: any) => ids.has(c.memberId));
|
||||||
|
if (cfg.targetChoreId)
|
||||||
|
team = team.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
||||||
|
if (cfg.type === 'threshold' && cfg.thresholdType === 'percent') {
|
||||||
|
teamTotal = Math.round((team.length / totalDue) * 100);
|
||||||
|
} else if (cfg.type === 'threshold') {
|
||||||
|
teamTotal = team.reduce((sum: number, c: any) => {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
} else if (cfg.type === 'count') {
|
||||||
current = memberCompletions.length;
|
teamTotal = team.length;
|
||||||
}
|
|
||||||
return { memberId: m.id, name: m.name, current };
|
|
||||||
});
|
|
||||||
const qualified = scored.filter(
|
|
||||||
(s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)
|
|
||||||
);
|
|
||||||
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
|
||||||
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
|
||||||
|
|
||||||
if (existingRewards.length > 0) {
|
|
||||||
const existing = existingRewards[0];
|
|
||||||
const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0;
|
|
||||||
if (!stillValid && existing.status !== 'claimed') {
|
|
||||||
try {
|
|
||||||
await pb.collection('rewards').delete(existing.id);
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const members =
|
||||||
if (winner && existingRewards.length === 0) {
|
cfg.target === 'individual' && cfg.memberId
|
||||||
await pb.collection('rewards').create(rewardData(winner.memberId));
|
? allMembers.filter((m: any) => m.id === cfg.memberId)
|
||||||
createdReward = true;
|
: allMembers;
|
||||||
|
for (const m of members) {
|
||||||
|
const achieved =
|
||||||
|
cfg.target === 'collaborative' ? met(teamTotal) : met(scoreFor(m.id));
|
||||||
|
await reconcile(m.id, achieved);
|
||||||
|
await purgeStale(m.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,14 +45,40 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) {
|
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) {
|
||||||
const { assignedChoreId, date } = body;
|
const { assignedChoreId, date } = body;
|
||||||
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
|
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
|
||||||
|
// Backdated catch-up (yesterday mode) stamps the completion for the viewed
|
||||||
|
// day, never "now" — the logical `date` always comes from the client, and
|
||||||
|
// an explicit `completedAt` is honoured when it parses (validated ISO,
|
||||||
|
// defaulting to now). Server-side period windows are always computed from
|
||||||
|
// the real current day, so a yesterday stamp can't shift window logic.
|
||||||
|
let completedAt = new Date().toISOString();
|
||||||
|
if (typeof body.completedAt === 'string') {
|
||||||
|
const t = new Date(body.completedAt).getTime();
|
||||||
|
if (Number.isFinite(t)) completedAt = new Date(t).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
const choreList = await pb
|
// Chore existence is checked as superuser so a stale client ID (parent
|
||||||
.collection('assigned_chores')
|
// re-saved the chores grid → old assigned rows deleted, new ids issued)
|
||||||
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` });
|
// surfaces as a clear "gone" error instead of PB's generic
|
||||||
const chore = choreList?.[0];
|
// "resource cannot be found" relation-validation 400. The child's own
|
||||||
|
// token is still used for the completion writes below (PB rules enforce).
|
||||||
|
let chore: any;
|
||||||
|
try {
|
||||||
|
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(getOne-fail) member=${memberId} fam=${famId} chore=${assignedChoreId} date=${date} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
|
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
||||||
|
}
|
||||||
|
if (!chore || chore.famId !== famId) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(fam-mismatch) member=${memberId} sessionFam=${famId} chore=${assignedChoreId} choreFam=${chore?.famId} choreMember=${chore?.memberId} date=${date}`
|
||||||
|
);
|
||||||
|
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
||||||
|
}
|
||||||
const isTodo = chore?.isTodo;
|
const isTodo = chore?.isTodo;
|
||||||
const isShared = chore?.shared === true || !chore?.memberId;
|
const isShared = chore?.shared === true || !chore?.memberId;
|
||||||
// Only one-off shared todos are claimed (winner takes it). Recurring
|
// Only one-off shared todos are claimed (winner takes it). Recurring
|
||||||
@@ -81,7 +107,14 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
|||||||
if (isClaimable && existing[0].memberId === memberId) {
|
if (isClaimable && existing[0].memberId === memberId) {
|
||||||
await claimChore(assignedChoreId, famId, '');
|
await claimChore(assignedChoreId, famId, '');
|
||||||
}
|
}
|
||||||
evaluateFam(pb, famId).catch(() => {});
|
// Bonus evaluation is awaited (not fire-and-forget) so failures are
|
||||||
|
// visible in the server log instead of silently swallowing missed payouts.
|
||||||
|
// The client already updates optimistically, so this doesn't block the UI.
|
||||||
|
try {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[evaluateFam] after un-complete:', e);
|
||||||
|
}
|
||||||
return { completed: false };
|
return { completed: false };
|
||||||
}
|
}
|
||||||
let rewardId: string | undefined;
|
let rewardId: string | undefined;
|
||||||
@@ -103,7 +136,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
|||||||
memberId,
|
memberId,
|
||||||
assignedChoreId,
|
assignedChoreId,
|
||||||
date,
|
date,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt,
|
||||||
...(rewardId ? { rewardId } : {})
|
...(rewardId ? { rewardId } : {})
|
||||||
});
|
});
|
||||||
// Claim a shared one-off todo by setting memberId (recurring shared
|
// Claim a shared one-off todo by setting memberId (recurring shared
|
||||||
@@ -111,7 +144,11 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
|||||||
if (isClaimable) {
|
if (isClaimable) {
|
||||||
await claimChore(assignedChoreId, famId, memberId);
|
await claimChore(assignedChoreId, famId, memberId);
|
||||||
}
|
}
|
||||||
evaluateFam(pb, famId).catch(() => {});
|
try {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[evaluateFam] after toggle:', e);
|
||||||
|
}
|
||||||
return { completed: true, record };
|
return { completed: true, record };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +166,10 @@ export async function revoke(pb: any, famId: string, completionId: string) {
|
|||||||
if (chore?.shared === true) {
|
if (chore?.shared === true) {
|
||||||
await claimChore(chore.id, famId, '');
|
await claimChore(chore.id, famId, '');
|
||||||
}
|
}
|
||||||
evaluateFam(pb, famId).catch(() => {});
|
try {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[evaluateFam] after revoke:', e);
|
||||||
|
}
|
||||||
return { revoked: true };
|
return { revoked: true };
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,7 @@ export async function getProfile(pb: any, famId: string, userId: string) {
|
|||||||
pattern: rec.pattern || '',
|
pattern: rec.pattern || '',
|
||||||
themeSize: rec.themeSize || '',
|
themeSize: rec.themeSize || '',
|
||||||
themeOpacity: rec.themeOpacity || '',
|
themeOpacity: rec.themeOpacity || '',
|
||||||
|
partyEmoji: rec.partyEmoji || '',
|
||||||
email: rec.email || ''
|
email: rec.email || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,6 +93,8 @@ export async function updateProfile(
|
|||||||
if (data.pattern !== undefined) patch.pattern = data.pattern;
|
if (data.pattern !== undefined) patch.pattern = data.pattern;
|
||||||
if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
|
if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
|
||||||
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
|
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
|
||||||
|
if (data.partyEmoji !== undefined)
|
||||||
|
patch.partyEmoji = String(data.partyEmoji).trim().slice(0, 8) || '🐖';
|
||||||
if (data.email !== undefined) patch.email = data.email;
|
if (data.email !== undefined) patch.email = data.email;
|
||||||
const rec = await pb.collection('users').update(userId, patch);
|
const rec = await pb.collection('users').update(userId, patch);
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +104,7 @@ export async function updateProfile(
|
|||||||
pattern: rec.pattern || '',
|
pattern: rec.pattern || '',
|
||||||
themeSize: rec.themeSize || '',
|
themeSize: rec.themeSize || '',
|
||||||
themeOpacity: rec.themeOpacity || '',
|
themeOpacity: rec.themeOpacity || '',
|
||||||
|
partyEmoji: rec.partyEmoji || '',
|
||||||
email: rec.email || ''
|
email: rec.email || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -169,8 +173,11 @@ export async function weeklySummary(pb: any, famId: string) {
|
|||||||
|
|
||||||
let bonusMoney = 0;
|
let bonusMoney = 0;
|
||||||
try {
|
try {
|
||||||
|
// Incoming cash counts too (unclaimed/requested, e.g. achieved but
|
||||||
|
// not yet approved pocket money) — this is the "earned this week"
|
||||||
|
// figure, not just banked cash.
|
||||||
const cashRewards = await pb.collection('rewards').getFullList({
|
const cashRewards = await pb.collection('rewards').getFullList({
|
||||||
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'`
|
||||||
});
|
});
|
||||||
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -199,7 +206,10 @@ export async function releaseWeek(pb: any, famId: string) {
|
|||||||
if (!fam) throw new Error('Fam not found');
|
if (!fam) throw new Error('Fam not found');
|
||||||
|
|
||||||
const wsToday = weekStart(payday, tz);
|
const wsToday = weekStart(payday, tz);
|
||||||
const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz));
|
// Settlement lands on payday itself — the SIXTH day of the wsToday week
|
||||||
|
// (weeks close on payday), not on weekStart.
|
||||||
|
const paydayDate = addDaysStr(wsToday, 6);
|
||||||
|
const target = new Date(wallClockToUtc(paydayDate, paydayTime || '18:00', tz));
|
||||||
if (Date.now() < target.getTime()) {
|
if (Date.now() < target.getTime()) {
|
||||||
return {
|
return {
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -273,7 +283,7 @@ export async function completeWeek(pb: any, famId: string) {
|
|||||||
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
||||||
}),
|
}),
|
||||||
pb.collection('rewards').getFullList({
|
pb.collection('rewards').getFullList({
|
||||||
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
filter: `famId = '${famId}' && rewardType = 'cash' && date >= '${ws}'`
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function createServices(
|
|||||||
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
|
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
|
||||||
},
|
},
|
||||||
completions: {
|
completions: {
|
||||||
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
|
toggle: (famId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) =>
|
||||||
completionsSvc.toggle(pb, famId, uid, body),
|
completionsSvc.toggle(pb, famId, uid, body),
|
||||||
revoke: (famId: string, completionId: string) =>
|
revoke: (famId: string, completionId: string) =>
|
||||||
completionsSvc.revoke(pb, famId, completionId)
|
completionsSvc.revoke(pb, famId, completionId)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { todayInTz, resolveTz } from '@shared/timezone';
|
import { todayInTz, resolveTz, wallClockToUtc } from '@shared/timezone';
|
||||||
import { famMeta } from './fam';
|
import { famMeta } from './fam';
|
||||||
import { pbAdmin } from '$lib/server/pocketbase';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ function resolveServerTz(tz?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A `once` goal is finished the moment its reward is approved — complete the
|
// A `once` goal is finished the moment its reward is approved — complete the
|
||||||
// config here (not just in evaluateFam, which only runs on chore toggles) so
|
// config here (not just in evaluateFam, which runs on toggles/revoke/payday) so
|
||||||
// the child dashboard stops offering it even before the next toggle.
|
// the child dashboard stops offering it even before the next toggle.
|
||||||
// Config write via superuser: bonus_configs updates are parent-only.
|
// Config write via superuser: bonus_configs updates are parent-only.
|
||||||
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible
|
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible
|
||||||
@@ -26,24 +26,35 @@ async function completeOnceConfig(famId: string, reward: any) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertPaydayUnlocked(reward: any, tz?: string) {
|
// Payday-gated rewards (claimable 'payday', e.g. pocket money) unlock on the
|
||||||
|
// settle DATE at the fam's configured payday TIME — not midnight. Anything
|
||||||
|
// else (claimable 'immediate'/unset, e.g. manual triggers) is always claimable.
|
||||||
|
export function assertPaydayUnlocked(reward: any, tz?: string, paydayTime?: string) {
|
||||||
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
|
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
|
||||||
const today = todayInTz(resolveServerTz(tz));
|
const resolvedTz = resolveServerTz(tz);
|
||||||
if (today < reward.settleDate) {
|
const settle = (reward.settleDate as string).slice(0, 10);
|
||||||
throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`);
|
const today = todayInTz(resolvedTz);
|
||||||
|
if (today < settle) {
|
||||||
|
throw new Error(`This bonus pays out on payday (${settle}) — hang tight!`);
|
||||||
|
}
|
||||||
|
if (today === settle && paydayTime && /^\d{2}:\d{2}$/.test(paydayTime)) {
|
||||||
|
const unlock = wallClockToUtc(settle, paydayTime, resolvedTz);
|
||||||
|
if (Date.now() < unlock) {
|
||||||
|
throw new Error(`This bonus unlocks at ${paydayTime} on payday — hang tight!`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member claim → status 'requested' (pending parent approval).
|
// Member claim → status 'requested' (pending parent approval).
|
||||||
export async function claim(pb: any, famId: string, id: string) {
|
export async function claim(pb: any, famId: string, id: string) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const { tz } = await famMeta(pb, famId);
|
const { tz, paydayTime } = await famMeta(pb, famId);
|
||||||
const found = await pb
|
const found = await pb
|
||||||
.collection('rewards')
|
.collection('rewards')
|
||||||
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
|
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
|
||||||
const reward = found?.[0];
|
const reward = found?.[0];
|
||||||
if (!reward) throw new Error('Reward not found');
|
if (!reward) throw new Error('Reward not found');
|
||||||
assertPaydayUnlocked(reward, tz);
|
assertPaydayUnlocked(reward, tz, paydayTime);
|
||||||
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
|
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +70,14 @@ export async function approve(pb: any, famId: string, id: string) {
|
|||||||
|
|
||||||
export async function requestAll(pb: any, famId: string, memberId: string) {
|
export async function requestAll(pb: any, famId: string, memberId: string) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const { tz } = await famMeta(pb, famId);
|
const { tz, paydayTime } = await famMeta(pb, famId);
|
||||||
const rewards = await pb.collection('rewards').getFullList({
|
const rewards = await pb.collection('rewards').getFullList({
|
||||||
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
|
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
|
||||||
});
|
});
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const r of rewards) {
|
for (const r of rewards) {
|
||||||
try {
|
try {
|
||||||
assertPaydayUnlocked(r, tz);
|
assertPaydayUnlocked(r, tz, paydayTime);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { famMeta } from './fam';
|
|||||||
|
|
||||||
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
|
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
|
||||||
// parametric form of `weekStart()` so we can bucket historical weeks, not
|
// parametric form of `weekStart()` so we can bucket historical weeks, not
|
||||||
// just the current one.
|
// just the current one. Weeks close on payday (open the day after).
|
||||||
function weekStartFor(dateStr: string, payday: number, tz: string): string {
|
function weekStartFor(dateStr: string, payday: number, tz: string): string {
|
||||||
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
|
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
|
||||||
const back = (((wd - payday) % 7) + 7) % 7;
|
const sincePayday = (((wd - payday) % 7) + 7) % 7;
|
||||||
|
const back = sincePayday === 0 ? 6 : sincePayday - 1;
|
||||||
return addDaysStr(dateStr, -back);
|
return addDaysStr(dateStr, -back);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export interface SessionUser {
|
|||||||
pattern?: string;
|
pattern?: string;
|
||||||
themeSize?: string;
|
themeSize?: string;
|
||||||
themeOpacity?: string;
|
themeOpacity?: string;
|
||||||
|
partyEmoji?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,32 @@ class FamStore {
|
|||||||
async resync() {
|
async resync() {
|
||||||
if (!this.initialized || this.destroyed || this.resyncing) return;
|
if (!this.initialized || this.destroyed || this.resyncing) return;
|
||||||
this.resyncing = true;
|
this.resyncing = true;
|
||||||
|
// Retry with backoff: a resync fired at the leading edge of a blip
|
||||||
|
// (QUIC drop, Tailscale relay flap) usually succeeds a second later.
|
||||||
|
// The dot stays amber until one attempt fully lands.
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt));
|
||||||
|
if (this.destroyed) break;
|
||||||
try {
|
try {
|
||||||
|
await this.fetchAll();
|
||||||
|
if (this.destroyed) return;
|
||||||
|
// A completed resync proves the pipe is healthy again.
|
||||||
|
this.connectionDown = false;
|
||||||
|
lastErr = null;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr) {
|
||||||
|
console.error('FamStore.resync failed:', lastErr);
|
||||||
|
if (!this.destroyed) this.connectionDown = true;
|
||||||
|
}
|
||||||
|
this.resyncing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAll() {
|
||||||
const [
|
const [
|
||||||
membersRes,
|
membersRes,
|
||||||
templatesRes,
|
templatesRes,
|
||||||
@@ -203,14 +228,6 @@ class FamStore {
|
|||||||
this.bonusTemplates = bonusTemplatesRes;
|
this.bonusTemplates = bonusTemplatesRes;
|
||||||
this.rewards = rewardsRes;
|
this.rewards = rewardsRes;
|
||||||
this.seasons = seasonsRes;
|
this.seasons = seasonsRes;
|
||||||
// A completed resync proves the pipe is healthy again.
|
|
||||||
this.connectionDown = false;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('FamStore.resync failed:', e);
|
|
||||||
if (!this.destroyed) this.connectionDown = true;
|
|
||||||
} finally {
|
|
||||||
this.resyncing = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private connWatchers: (() => void)[] = [];
|
private connWatchers: (() => void)[] = [];
|
||||||
@@ -304,7 +321,12 @@ class FamStore {
|
|||||||
// and from PB subscribe SSE for multi-user realtime.
|
// and from PB subscribe SSE for multi-user realtime.
|
||||||
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
||||||
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
||||||
if (action === 'create') return [record, ...list];
|
// A create for an id we already hold (optimistic add + SSE echo) is
|
||||||
|
// an update, not a second row.
|
||||||
|
if (action === 'create')
|
||||||
|
return list.some((x) => x.id === record.id)
|
||||||
|
? list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
|
||||||
|
: [record, ...list];
|
||||||
if (action === 'update')
|
if (action === 'update')
|
||||||
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
||||||
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
||||||
|
|||||||
@@ -2,8 +2,26 @@
|
|||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import NoticeDialog from '$lib/components/NoticeDialog.svelte';
|
import NoticeDialog from '$lib/components/NoticeDialog.svelte';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
// Last-resort net: PocketBase SDK fetch failures (the minified `send()`
|
||||||
|
// frame in prod stacks) otherwise surface as unhandled rejections with no
|
||||||
|
// UI signal. Network-shaped ones flip the TopNav status dot; the fam
|
||||||
|
// layout's resync clears it once the pipe is healthy again.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const msg = String((e.reason as any)?.message || e.reason || '');
|
||||||
|
if (
|
||||||
|
/TypeError|Failed to fetch|Load failed|NetworkError|network|offline|ERR_QUIC|ERR_NETWORK|ERR_INTERNET/i.test(
|
||||||
|
msg
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
famStore.connectionDown = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|||||||
@@ -134,7 +134,14 @@ export async function load(event) {
|
|||||||
chat = await resolveChatIdentity(session, pbToken);
|
chat = await resolveChatIdentity(session, pbToken);
|
||||||
// fams is superadmin-only (non-realtime). Fetched server-side for both
|
// fams is superadmin-only (non-realtime). Fetched server-side for both
|
||||||
// roles; also recomputes + persists the derived `active` flag.
|
// roles; also recomputes + persists the derived `active` flag.
|
||||||
const res = await ensureFamAccess(famId).catch(() => null);
|
// Retried: a post-deploy PB blip here blanks `fam`, which both hides
|
||||||
|
// data AND disables the canonical slug redirect below (letting /{id}
|
||||||
|
// URLs persist).
|
||||||
|
let res: any = null;
|
||||||
|
for (let i = 0; i < 3 && !res; i++) {
|
||||||
|
if (i > 0) await new Promise((r) => setTimeout(r, 400 * i));
|
||||||
|
res = await ensureFamAccess(famId).catch(() => null);
|
||||||
|
}
|
||||||
if (res) {
|
if (res) {
|
||||||
fam = res.fam;
|
fam = res.fam;
|
||||||
famAccess = res.access;
|
famAccess = res.access;
|
||||||
|
|||||||
@@ -69,7 +69,12 @@
|
|||||||
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 isDemo = $state(data.demoMode);
|
// Demo UI restrictions apply only inside the demo family itself — the
|
||||||
|
// global `demo` flag must never degrade real families (setting it in
|
||||||
|
// prod once hid every fam's dashboard + settings links).
|
||||||
|
let isDemo = $derived(
|
||||||
|
!!data.demoMode && (page.data.famSlug ?? page.params.fam) === 'showboaters'
|
||||||
|
);
|
||||||
let famName = $derived(
|
let famName = $derived(
|
||||||
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
|
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
|
||||||
);
|
);
|
||||||
@@ -78,6 +83,14 @@
|
|||||||
// Settings stays usable while paused so admins can apply a code / manage billing.
|
// Settings stays usable while paused so admins can apply a code / manage billing.
|
||||||
let hasAuth = $derived(!!data.session);
|
let hasAuth = $derived(!!data.session);
|
||||||
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
|
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
|
||||||
|
// Expired-token zombie state: public PB rules still let logged-out users
|
||||||
|
// READ everything, so the dashboard looks alive while every write fails.
|
||||||
|
// Say so explicitly (join/switch/picker flows render their own UI).
|
||||||
|
let showLoggedOut = $derived(
|
||||||
|
!data.session &&
|
||||||
|
!pickerMode &&
|
||||||
|
!['join', 'switch'].includes(page.url.pathname.split('/')[2] || '')
|
||||||
|
);
|
||||||
|
|
||||||
// ── Post-checkout activation (event-driven) ──
|
// ── Post-checkout activation (event-driven) ──
|
||||||
// Landing with ?checkout=return: if the webhook has already landed we show
|
// Landing with ?checkout=return: if the webhook has already landed we show
|
||||||
@@ -282,6 +295,28 @@
|
|||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<div class="page-content" class:locked>{@render children()}</div>
|
<div class="page-content" class:locked>{@render children()}</div>
|
||||||
|
|
||||||
|
{#if showLoggedOut}
|
||||||
|
<div class="disabled-overlay loggedout-overlay">
|
||||||
|
<div class="disabled-card">
|
||||||
|
<strong>You're logged out</strong>
|
||||||
|
<span>
|
||||||
|
Your session expired, so everything below is read-only — toggles and
|
||||||
|
claims won't work until you log back in.
|
||||||
|
</span>
|
||||||
|
<span class="loggedout-actions">
|
||||||
|
<a class="btn-primary" href="/login">Parent log in</a>
|
||||||
|
{#if page.params.username}
|
||||||
|
<a
|
||||||
|
class="btn-secondary"
|
||||||
|
href={`/${data.famSlug || page.params.fam}/join/${page.params.username}`}
|
||||||
|
>Rejoin as {page.params.username}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="hint">Kids: re-open your invite link, or ask a parent to send it again.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if locked}
|
{#if locked}
|
||||||
<div class="disabled-overlay">
|
<div class="disabled-overlay">
|
||||||
<div class="disabled-card">
|
<div class="disabled-card">
|
||||||
@@ -482,6 +517,34 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
/* Logged-out card must be clickable (the paused overlay is display-only). */
|
||||||
|
.loggedout-overlay {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.loggedout-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.loggedout-actions a {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.loggedout-actions .btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.loggedout-actions .btn-secondary {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.disabled-card .hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
.chat-toggle {
|
.chat-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
@@ -107,6 +107,39 @@
|
|||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
// ─── All-time per-member stats ───
|
||||||
|
let allTimeByMember = $derived.by(() => {
|
||||||
|
const map = new Map<string, { points: number; cash: number; chores: number }>();
|
||||||
|
for (const m of trend as any[]) {
|
||||||
|
const pts = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.points) || 0), 0);
|
||||||
|
const cash = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.cash) || 0), 0);
|
||||||
|
const chores = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.chores) || 0), 0);
|
||||||
|
map.set(m.memberId, { points: pts, cash, chores });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
let goalsAllTimeByMember = $derived.by(() => {
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, { count: number; names: string[] }>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
|
||||||
|
const entry = map.get(r.memberId) || { count: 0, names: [] };
|
||||||
|
entry.count++;
|
||||||
|
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
|
||||||
|
map.set(r.memberId, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
let pocketMoneyAllTimeByMember = $derived.by(() => {
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || !pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
map.set(r.memberId, (map.get(r.memberId) || 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
|
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
|
||||||
let trend = $derived.by(() => {
|
let trend = $derived.by(() => {
|
||||||
@@ -171,30 +204,27 @@
|
|||||||
function todayPotentialFor(memberId: string) {
|
function todayPotentialFor(memberId: string) {
|
||||||
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
|
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
|
||||||
}
|
}
|
||||||
// Max points a full "all daily chores done" day is worth for a member.
|
// All-time completion rate for a member: all chores ever done ÷ (current
|
||||||
function dailyPointsPotential(memberId: string) {
|
// weekly potential × weeks active). Bounded and meaningful, unlike the old
|
||||||
return assigned
|
// points-per-day average (bonus points + weeklies done in one day could
|
||||||
.filter((a: any) => a.memberId === memberId && a.frequency === 'daily')
|
// push it past a "perfect day" — e.g. Jethro's 100%).
|
||||||
.reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0);
|
function overallRateFor(memberId: string): { done: number; total: number; pct: number } {
|
||||||
}
|
|
||||||
// All-time average daily points for a member, from the server-computed
|
|
||||||
// weekly series (all history, not just this week). Days active runs from
|
|
||||||
// their earliest recorded week through today (min 1).
|
|
||||||
function avgDailyPointsFor(memberId: string): number {
|
|
||||||
const line = trend.find((m: any) => m.memberId === memberId);
|
const line = trend.find((m: any) => m.memberId === memberId);
|
||||||
const weeks = line?.weeks || [];
|
const weeks = line?.weeks || [];
|
||||||
if (weeks.length === 0) return 0;
|
const done = weeks.reduce((sum: number, w: any) => sum + (Number(w.chores) || 0), 0);
|
||||||
const total = weeks.reduce((sum: number, w: any) => sum + (Number(w.points) || 0), 0);
|
const potential = weeklyPotentialFor(memberId);
|
||||||
|
if (weeks.length === 0 || potential <= 0) return { done, total: 0, pct: 0 };
|
||||||
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
|
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
|
||||||
const today = todayInTz(famTz);
|
const today = todayInTz(famTz);
|
||||||
const days = Math.max(
|
const weeksActive = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.round(
|
Math.floor(
|
||||||
(new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
|
(new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
|
||||||
86400000
|
(7 * 86400000)
|
||||||
) + 1
|
) + 1
|
||||||
);
|
);
|
||||||
return total / days;
|
const total = potential * weeksActive;
|
||||||
|
return { done, total, pct: Math.min(100, Math.round((done / total) * 100)) };
|
||||||
}
|
}
|
||||||
// Today's completion count — tolerant of UTC vs fam-local date keys.
|
// Today's completion count — tolerant of UTC vs fam-local date keys.
|
||||||
function todayDone(s: any): number {
|
function todayDone(s: any): number {
|
||||||
@@ -215,6 +245,7 @@
|
|||||||
if (!summary?.summaries?.length) return;
|
if (!summary?.summaries?.length) return;
|
||||||
|
|
||||||
if (choresCanvas && summary.daysInWeek?.length) {
|
if (choresCanvas && summary.daysInWeek?.length) {
|
||||||
|
// daysInWeek runs payday+1..payday (weeks close on payday).
|
||||||
const days = summary.daysInWeek;
|
const days = summary.daysInWeek;
|
||||||
const labels = days.map((d: string) =>
|
const labels = days.map((d: string) =>
|
||||||
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
|
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
|
||||||
@@ -277,10 +308,11 @@
|
|||||||
}
|
}
|
||||||
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
|
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
|
||||||
if (scoreCanvas) {
|
if (scoreCanvas) {
|
||||||
|
const overall = overallRateFor(s.memberId);
|
||||||
donutCharts[`${s.memberId}:score`] = makeDonut(
|
donutCharts[`${s.memberId}:score`] = makeDonut(
|
||||||
scoreCanvas,
|
scoreCanvas,
|
||||||
avgDailyPointsFor(s.memberId),
|
overall.done,
|
||||||
dailyPointsPotential(s.memberId),
|
overall.total,
|
||||||
s.memberColor
|
s.memberColor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -335,19 +367,8 @@
|
|||||||
subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`}
|
subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`}
|
||||||
hero
|
hero
|
||||||
>
|
>
|
||||||
<!-- All-time family totals, large in the header -->
|
<!-- Shared-computer toggle (left) + all-time totals (right), one line -->
|
||||||
<div class="vh-stats grow text-right">
|
<div class="vh-toprow">
|
||||||
<span class="vh-stat">
|
|
||||||
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
|
|
||||||
<span class="vh-lbl">points all time</span>
|
|
||||||
</span>
|
|
||||||
<span class="vh-stat">
|
|
||||||
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
|
|
||||||
<span class="vh-lbl">cash earned all time</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Shared-computer toggle, bottom-left of the hero -->
|
|
||||||
<div class="vh-foot">
|
|
||||||
<button
|
<button
|
||||||
class="shared-hero-toggle"
|
class="shared-hero-toggle"
|
||||||
class:on={sharedDevice.on}
|
class:on={sharedDevice.on}
|
||||||
@@ -358,6 +379,16 @@
|
|||||||
{@html monitorIcon}
|
{@html monitorIcon}
|
||||||
<span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span>
|
<span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="vh-stats text-right">
|
||||||
|
<span class="vh-stat">
|
||||||
|
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
|
||||||
|
<span class="vh-lbl">points all time</span>
|
||||||
|
</span>
|
||||||
|
<span class="vh-stat">
|
||||||
|
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
|
||||||
|
<span class="vh-lbl">cash earned all time</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ViewHeader>
|
</ViewHeader>
|
||||||
|
|
||||||
@@ -413,10 +444,10 @@
|
|||||||
{@const tdTotal = todayPotentialFor(s.memberId)}
|
{@const tdTotal = todayPotentialFor(s.memberId)}
|
||||||
{@const tdDone = todayDone(s)}
|
{@const tdDone = todayDone(s)}
|
||||||
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
|
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
|
||||||
{@const scPot = dailyPointsPotential(s.memberId)}
|
{@const sc = overallRateFor(s.memberId)}
|
||||||
{@const scVal = avgDailyPointsFor(s.memberId)}
|
|
||||||
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
|
||||||
{@const goals = goalsByMember.get(s.memberId)}
|
{@const goals = goalsByMember.get(s.memberId)}
|
||||||
|
{@const goalsAll = goalsAllTimeByMember.get(s.memberId)}
|
||||||
|
{@const at = allTimeByMember.get(s.memberId)}
|
||||||
<div class="column col-member" style="--mc: {s.memberColor}">
|
<div class="column col-member" style="--mc: {s.memberColor}">
|
||||||
<div class="member-head">
|
<div class="member-head">
|
||||||
<span class="dot" style="background:{s.memberColor}"></span>
|
<span class="dot" style="background:{s.memberColor}"></span>
|
||||||
@@ -438,32 +469,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="donut-wrap">
|
<div class="donut-wrap">
|
||||||
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
|
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
|
||||||
<div class="donut-center" style="color:{s.memberColor}">{scPct}%</div>
|
<div class="donut-center" style="color:{s.memberColor}">{sc.pct}%</div>
|
||||||
<span class="donut-label">Avg/day</span>
|
<span class="donut-label">Overall</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-stats">
|
<div class="member-stats compact">
|
||||||
<div class="stat-row">
|
<div class="mstat" title="Points this week: {s.pointsEarned} · Cash: £{Number(s.moneyEarned || 0).toFixed(2)} · Pocket money: {pocketMoneyByMember.get(s.memberId) ?? 0}">
|
||||||
<span class="stat-lbl">Points</span>
|
<span class="mstat-tag">Week</span>
|
||||||
<span class="stat-val pts">{s.pointsEarned}</span>
|
<span class="mstat-main"><b class="pts">{s.pointsEarned}</b> pts · <b class="cash">£{Number(s.moneyEarned || 0).toFixed(2)}</b> · {wkDone}/{wkTotal}</span>
|
||||||
|
{#if (goals?.count ?? 0) > 0}<span class="mstat-goals" title={goals?.names.join(' · ')}>🎉{goals?.count}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="mstat dim" title="Points all time: {at?.points ?? s.pointsEarned} · Cash: £{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)} · Pocket money: {pocketMoneyAllTimeByMember.get(s.memberId) ?? 0}">
|
||||||
<span class="stat-lbl">Cash</span>
|
<span class="mstat-tag">All time</span>
|
||||||
<span class="stat-val cash">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
|
<span class="mstat-main"><b>{at?.points ?? s.pointsEarned}</b> pts · <b>£{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)}</b> · {sc.done}/{sc.total}</span>
|
||||||
</div>
|
{#if (goalsAll?.count ?? 0) > 0}<span class="mstat-goals" title={goalsAll?.names.join(' · ')}>🎉{goalsAll?.count}</span>{/if}
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-lbl">Done</span>
|
|
||||||
<span class="stat-val">{wkDone}/{wkTotal}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-lbl">Goals 🎉</span>
|
|
||||||
<span class="stat-val goals" title={goals?.names.join(' · ')}
|
|
||||||
>{goals?.count ?? 0}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-lbl">Pocket Money</span>
|
|
||||||
<span class="stat-val pm">{pocketMoneyByMember.get(s.memberId) ?? 0}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,12 +545,14 @@
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
/* Shared-computer toggle row, bottom-left of the hero */
|
/* Shared-computer toggle (left) + all-time totals (right), one line */
|
||||||
.vh-foot {
|
.vh-toprow {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
margin-top: 0.75rem;
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
.shared-hero-toggle {
|
.shared-hero-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -673,7 +694,7 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-top: 4px solid #6366f1;
|
border-top: 4px solid #6366f1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem;
|
padding: 0.9rem;
|
||||||
background: #fafbff;
|
background: #fafbff;
|
||||||
}
|
}
|
||||||
.col-member {
|
.col-member {
|
||||||
@@ -688,6 +709,7 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -720,18 +742,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.85rem;
|
gap: 0.7rem;
|
||||||
margin: 3rem 0;
|
margin: 1.1rem 0 1.2rem;
|
||||||
}
|
}
|
||||||
.donut-wrap {
|
.donut-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 62px;
|
width: 54px;
|
||||||
height: 62px;
|
height: 54px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.donut-wrap canvas {
|
.donut-wrap canvas {
|
||||||
max-width: 62px;
|
max-width: 54px;
|
||||||
max-height: 62px;
|
max-height: 54px;
|
||||||
}
|
}
|
||||||
.donut-center {
|
.donut-center {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -759,15 +781,70 @@
|
|||||||
.member-stats {
|
.member-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.45rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.member-stats.compact .mstat {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #374151;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.member-stats.compact .mstat.dim {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.mstat-tag {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mstat-main {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mstat-main b {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.mstat-main b.pts {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
.mstat-main b.cash {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.mstat-goals {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
.stat-row {
|
.stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.stat-group-lbl {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.stat-group-lbl:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.stat-lbl {
|
.stat-lbl {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export async function load(event) {
|
|||||||
verified: false,
|
verified: false,
|
||||||
memberName: session?.name || '',
|
memberName: session?.name || '',
|
||||||
memberColor: session?.color || '',
|
memberColor: session?.color || '',
|
||||||
|
partyEmoji: (session as any)?.partyEmoji || '',
|
||||||
templates: [] as never[],
|
templates: [] as never[],
|
||||||
assigned: [] as never[],
|
assigned: [] as never[],
|
||||||
completions: [] as never[],
|
completions: [] as never[],
|
||||||
@@ -111,6 +112,7 @@ export async function load(event) {
|
|||||||
verified: true,
|
verified: true,
|
||||||
memberName: session?.name || '',
|
memberName: session?.name || '',
|
||||||
memberColor: session?.color || '',
|
memberColor: session?.color || '',
|
||||||
|
partyEmoji: (session as any)?.partyEmoji || '',
|
||||||
templates: chores.templates || [],
|
templates: chores.templates || [],
|
||||||
assigned: chores.assigned || [],
|
assigned: chores.assigned || [],
|
||||||
completions: chores.completions || [],
|
completions: chores.completions || [],
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
clockIcon,
|
clockIcon,
|
||||||
giftIcon,
|
giftIcon,
|
||||||
handCoinsIcon,
|
handCoinsIcon,
|
||||||
moneyIcon,
|
|
||||||
coinsIcon,
|
coinsIcon,
|
||||||
walletIcon,
|
walletIcon,
|
||||||
chevronLeftIcon,
|
chevronLeftIcon,
|
||||||
chevronRightIcon
|
chevronRightIcon
|
||||||
} from '$lib/components/icons';
|
} from '$lib/components/icons';
|
||||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||||
|
import PiggyBank from '@lucide/svelte/icons/piggy-bank';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
import {
|
import {
|
||||||
weekStart as tzWeekStart,
|
weekStart as tzWeekStart,
|
||||||
@@ -84,6 +84,18 @@
|
|||||||
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
|
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Yesterday's completions surface in the admin view for revoke — but never
|
||||||
|
// from the week's first day (yesterday would be payday = last week).
|
||||||
|
let yesterdayParent = $derived(addDaysStr(today, -1));
|
||||||
|
let yesterdayParentInWeek = $derived(
|
||||||
|
!!summary?.weekStart ? yesterdayParent >= (summary.weekStart as string).slice(0, 10) : true
|
||||||
|
);
|
||||||
|
function yesterdayCompletionsFor(memberId: string) {
|
||||||
|
if (!yesterdayParentInWeek) return [];
|
||||||
|
return parentCompletions.filter(
|
||||||
|
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === yesterdayParent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function choreNameFor(assignedChoreId: string): string {
|
function choreNameFor(assignedChoreId: string): string {
|
||||||
const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
|
const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
|
||||||
@@ -209,6 +221,15 @@
|
|||||||
let famId = $state(data.famId || '');
|
let famId = $state(data.famId || '');
|
||||||
let memberName = $state(data.memberName || '');
|
let memberName = $state(data.memberName || '');
|
||||||
let memberColor = $state(data.memberColor || '#6366f1');
|
let memberColor = $state(data.memberColor || '#6366f1');
|
||||||
|
// Party mascot: live member record first (updates without re-login),
|
||||||
|
// server load as fallback, pig as default.
|
||||||
|
let partyEmoji = $derived(
|
||||||
|
(famStore.initialized
|
||||||
|
? (famStore.members as any[]).find((m) => m.id === memberId)?.partyEmoji
|
||||||
|
: null) ||
|
||||||
|
(data as any).partyEmoji ||
|
||||||
|
'🐖'
|
||||||
|
);
|
||||||
let editingName = $state(false);
|
let editingName = $state(false);
|
||||||
let nameInput = $state('');
|
let nameInput = $state('');
|
||||||
let showColorPicker = $state(false);
|
let showColorPicker = $state(false);
|
||||||
@@ -245,9 +266,41 @@
|
|||||||
let todayChild = $derived(todayInTz(famTz));
|
let todayChild = $derived(todayInTz(famTz));
|
||||||
let togglingIds = $state<Set<string>>(new Set());
|
let togglingIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// ─── Kid-created todos ───
|
||||||
|
let showTodoModal = $state(false);
|
||||||
|
let newTodoName = $state('');
|
||||||
|
let todoSaving = $state(false);
|
||||||
|
let todoError = $state('');
|
||||||
|
async function submitTodo() {
|
||||||
|
const name = newTodoName.trim();
|
||||||
|
if (!name || todoSaving) return;
|
||||||
|
todoSaving = true;
|
||||||
|
todoError = '';
|
||||||
|
try {
|
||||||
|
const res = await memberApi.createTodo(name);
|
||||||
|
// Instant UI — the SSE echo dedupes on id via applyRecord.
|
||||||
|
famStore.applyRecord('assigned_chores', res.record, 'create');
|
||||||
|
newTodoName = '';
|
||||||
|
showTodoModal = false;
|
||||||
|
} catch (e) {
|
||||||
|
todoError = e instanceof Error ? e.message : 'Could not add todo';
|
||||||
|
} finally {
|
||||||
|
todoSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Payday countdown (child view) ───
|
// ─── Payday countdown (child view) ───
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
let eowFired = $state(false);
|
let eowFired = $state(false);
|
||||||
|
// Nyan-style payday party: rainbow banner + confetti when payday unlocks.
|
||||||
|
let paydayParty = $state(false);
|
||||||
|
let partyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
function startPaydayParty() {
|
||||||
|
paydayParty = true;
|
||||||
|
fire();
|
||||||
|
if (partyTimer) clearTimeout(partyTimer);
|
||||||
|
partyTimer = setTimeout(() => (paydayParty = false), 15000);
|
||||||
|
}
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = setInterval(() => (nowMs = Date.now()), 1000);
|
const id = setInterval(() => (nowMs = Date.now()), 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
@@ -269,6 +322,7 @@
|
|||||||
if (!pbToken || !famId) return;
|
if (!pbToken || !famId) return;
|
||||||
eowFired = true;
|
eowFired = true;
|
||||||
memberApi.payday(famId).catch(() => {});
|
memberApi.payday(famId).catch(() => {});
|
||||||
|
startPaydayParty();
|
||||||
});
|
});
|
||||||
|
|
||||||
function paydayWeekStart(): string {
|
function paydayWeekStart(): string {
|
||||||
@@ -290,11 +344,43 @@
|
|||||||
let weekEnd = $derived(addDays(weekStart, 6));
|
let weekEnd = $derived(addDays(weekStart, 6));
|
||||||
|
|
||||||
const isCurrentWeek = $derived(weekStart === currentWeek);
|
const isCurrentWeek = $derived(weekStart === currentWeek);
|
||||||
|
// ─── Yesterday catch-up mode (child only) ───
|
||||||
|
// Before 12pm fam-local, and only when yesterday is still inside the
|
||||||
|
// current week (never across payday — e.g. payday Sunday → no yesterdays
|
||||||
|
// on Monday), the hero offers a "forgot yesterday?" button that flips the
|
||||||
|
// kanban to yesterday's date so missed chores can be marked late.
|
||||||
|
let yesterdayStr = $derived(addDaysStr(todayChild, -1));
|
||||||
|
let yesterdayInWeek = $derived(yesterdayStr >= currentWeek);
|
||||||
|
let famHour = $derived.by(() => {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: famTz,
|
||||||
|
hour: 'numeric',
|
||||||
|
hour12: false
|
||||||
|
}).formatToParts(new Date(nowMs));
|
||||||
|
return Number(parts.find((p) => p.type === 'hour')?.value ?? 0) % 24;
|
||||||
|
} catch {
|
||||||
|
return new Date(nowMs).getHours();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let yesterdayAvailable = $derived(role === 'child' && yesterdayInWeek && famHour < 12);
|
||||||
|
let viewDate = $state<'today' | 'yesterday'>('today');
|
||||||
|
// Viewed day for the kanban: today normally, yesterday in catch-up mode.
|
||||||
|
let activeDate = $derived(
|
||||||
|
viewDate === 'yesterday' && yesterdayAvailable ? yesterdayStr : todayChild
|
||||||
|
);
|
||||||
|
let inYesterday = $derived(activeDate === yesterdayStr && yesterdayAvailable);
|
||||||
|
// Snap back to today when the mode stops being valid (past noon, or the
|
||||||
|
// day rolled over) so stale yesterday state can never linger.
|
||||||
|
$effect(() => {
|
||||||
|
if (!yesterdayAvailable && viewDate === 'yesterday') viewDate = 'today';
|
||||||
|
});
|
||||||
const daysLeft = $derived(
|
const daysLeft = $derived(
|
||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round(
|
Math.round(
|
||||||
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() -
|
// Payday closes the week on weekStart+6 (weeks run payday+1..payday).
|
||||||
|
(new Date(addDays(weekStart, 6) + 'T00:00:00').getTime() -
|
||||||
new Date(todayIso + 'T00:00:00').getTime()) /
|
new Date(todayIso + 'T00:00:00').getTime()) /
|
||||||
86400000
|
86400000
|
||||||
)
|
)
|
||||||
@@ -359,10 +445,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let dailyPending = $derived(
|
let dailyPending = $derived(
|
||||||
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
|
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, activeDate))
|
||||||
);
|
);
|
||||||
let weeklyPending = $derived(
|
let weeklyPending = $derived(
|
||||||
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
|
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, activeDate))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Traffic light: days until todo deadline (only non-emoji todos)
|
// Traffic light: days until todo deadline (only non-emoji todos)
|
||||||
@@ -380,7 +466,7 @@
|
|||||||
return 'blue';
|
return 'blue';
|
||||||
}
|
}
|
||||||
let completedToday = $derived(
|
let completedToday = $derived(
|
||||||
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
|
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === activeDate)
|
||||||
);
|
);
|
||||||
|
|
||||||
let weeklyTotal = $derived.by(() => {
|
let weeklyTotal = $derived.by(() => {
|
||||||
@@ -412,9 +498,7 @@
|
|||||||
const counts: number[] = [];
|
const counts: number[] = [];
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
const d = addDays(weekStart, i);
|
const d = addDays(weekStart, i);
|
||||||
counts.push(
|
counts.push(weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length);
|
||||||
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
@@ -500,6 +584,15 @@
|
|||||||
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||||||
}, 0)
|
}, 0)
|
||||||
);
|
);
|
||||||
|
// Week's cash-reward earnings (pocket money, bonuses) count while still
|
||||||
|
// pending AND once claimed — auto-created money-todo rewards are skipped
|
||||||
|
// (already counted via the chore completion above).
|
||||||
|
let weekRewardCash = $derived.by(() =>
|
||||||
|
weekRewards
|
||||||
|
.filter((r) => r.rewardType === 'cash' && !autoRewardIds.has(r.id))
|
||||||
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
|
);
|
||||||
|
let weekCashTotal = $derived(weekChoreCash + weekRewardCash);
|
||||||
let weekChorePoints = $derived.by(() =>
|
let weekChorePoints = $derived.by(() =>
|
||||||
weekCompletions.reduce((sum, c) => {
|
weekCompletions.reduce((sum, c) => {
|
||||||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||||||
@@ -539,9 +632,14 @@
|
|||||||
(r.status === 'unclaimed' || r.status === 'requested')
|
(r.status === 'unclaimed' || r.status === 'requested')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// Payday-gated bonus rewards unlock on their settleDate (stamped server-side).
|
// Payday-gated bonus rewards unlock on their settleDate at the fam's payday
|
||||||
|
// time (stamped server-side) — not midnight on the day.
|
||||||
function paydayLocked(r: any) {
|
function paydayLocked(r: any) {
|
||||||
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate;
|
if (r.claimable !== 'payday' || !r.settleDate) return false;
|
||||||
|
const settle = (r.settleDate as string).slice(0, 10);
|
||||||
|
if (todayChild < settle) return true;
|
||||||
|
if (todayChild > settle) return false;
|
||||||
|
return nowMs < new Date(wallClockToUtc(settle, paydayTime, famTz)).getTime();
|
||||||
}
|
}
|
||||||
let weekBonusTallies = $derived.by(() => {
|
let weekBonusTallies = $derived.by(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@@ -765,7 +863,7 @@
|
|||||||
'December'
|
'December'
|
||||||
];
|
];
|
||||||
function headDate(): Date {
|
function headDate(): Date {
|
||||||
return new Date(todayInTz(famTz) + 'T00:00:00');
|
return new Date(activeDate + 'T00:00:00');
|
||||||
}
|
}
|
||||||
function headDay(): number {
|
function headDay(): number {
|
||||||
return headDate().getDate();
|
return headDate().getDate();
|
||||||
@@ -839,18 +937,29 @@
|
|||||||
? c.assignedChoreId === chore.id &&
|
? c.assignedChoreId === chore.id &&
|
||||||
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||||||
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
|
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
|
||||||
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === todayChild;
|
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === activeDate;
|
||||||
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
||||||
return optimistic || completions.find(match);
|
return optimistic || completions.find(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(chore: AssignedChore) {
|
async function toggle(chore: AssignedChore) {
|
||||||
if (accessDisabled) return;
|
if (accessDisabled) return;
|
||||||
|
if (!pbToken) {
|
||||||
|
showToast('Logged out — please log in again to update chores.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (chore.isTodo && isTodoExpired(chore)) return;
|
if (chore.isTodo && isTodoExpired(chore)) return;
|
||||||
if (togglingIds.has(chore.id)) return;
|
if (togglingIds.has(chore.id)) return;
|
||||||
togglingIds = new Set(togglingIds).add(chore.id);
|
togglingIds = new Set(togglingIds).add(chore.id);
|
||||||
|
|
||||||
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
|
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, activeDate);
|
||||||
|
// Backdated (yesterday) completions are stamped for yesterday — never
|
||||||
|
// today's wall-clock time — so period/window logic can't double-count.
|
||||||
|
const stampDate = activeDate;
|
||||||
|
const stampAt =
|
||||||
|
stampDate === todayChild
|
||||||
|
? new Date().toISOString()
|
||||||
|
: new Date(wallClockToUtc(stampDate, '12:00', famTz)).toISOString();
|
||||||
if (wasCompleted) {
|
if (wasCompleted) {
|
||||||
const existing = findCompletion(chore);
|
const existing = findCompletion(chore);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -864,39 +973,46 @@
|
|||||||
famId,
|
famId,
|
||||||
memberId,
|
memberId,
|
||||||
assignedChoreId: chore.id,
|
assignedChoreId: chore.id,
|
||||||
date: todayChild,
|
date: stampDate,
|
||||||
completedAt: new Date().toISOString()
|
completedAt: stampAt
|
||||||
} as Completion,
|
} as Completion,
|
||||||
'create'
|
'create'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await memberApi.toggleCompletion(famId, chore.id, todayChild);
|
await memberApi.toggleCompletion(famId, chore.id, stampDate, stampAt);
|
||||||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
famStore.applyRecord('completions', optimistic, 'delete');
|
famStore.applyRecord('completions', optimistic, 'delete');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (wasCompleted) {
|
// A stale assigned ID (parent re-saved chores while this tab was
|
||||||
famStore.applyRecord(
|
// open in the background) leaves the chore untogglable. init() is
|
||||||
'completions',
|
// a no-op when already initialized, so force a resync() to drop
|
||||||
{
|
// the dead card. Never re-add completions for the gone chore —
|
||||||
id: 'revert-' + chore.id + '-' + Date.now(),
|
// that is what kept the error reoccurring after "refresh".
|
||||||
famId,
|
|
||||||
memberId,
|
|
||||||
assignedChoreId: chore.id,
|
|
||||||
date: todayChild,
|
|
||||||
completedAt: new Date().toISOString()
|
|
||||||
} as Completion,
|
|
||||||
'create'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
famStore.applyRecord('completions', optimistic, 'delete');
|
famStore.applyRecord('completions', optimistic, 'delete');
|
||||||
}
|
}
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (/CHORE_GONE/.test(msg)) {
|
||||||
|
// Drop the dead card synchronously so a second tap can't re-enter
|
||||||
|
// before the resync round-trip finishes (the repeated 400s).
|
||||||
|
famStore.applyRecord('assigned_chores', chore, 'delete');
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(drop-card) member=${memberId} fam=${famId} chore=${chore.id}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await famStore.resync();
|
||||||
|
} catch {}
|
||||||
|
showToast(
|
||||||
|
/CHORE_GONE/.test(msg)
|
||||||
|
? 'This chore was changed — list refreshed.'
|
||||||
|
: 'Toggle failed — please try again.'
|
||||||
|
);
|
||||||
console.error('Toggle failed:', e);
|
console.error('Toggle failed:', e);
|
||||||
} finally {
|
} finally {
|
||||||
const next = new Set(togglingIds);
|
const next = new Set(togglingIds);
|
||||||
@@ -976,6 +1092,7 @@
|
|||||||
<Card cols={1} title="Members">
|
<Card cols={1} title="Members">
|
||||||
{#each parentMembers as m}
|
{#each parentMembers as m}
|
||||||
{@const todays = todayCompletionsFor(m.id)}
|
{@const todays = todayCompletionsFor(m.id)}
|
||||||
|
{@const yesterdays = yesterdayCompletionsFor(m.id)}
|
||||||
{@const done = todays.length}
|
{@const done = todays.length}
|
||||||
{@const s = memberInSummary(m.id)}
|
{@const s = memberInSummary(m.id)}
|
||||||
{@const pm = parentBonusConfigs.find(
|
{@const pm = parentBonusConfigs.find(
|
||||||
@@ -1030,6 +1147,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if yesterdays.length > 0}
|
||||||
|
<p class="section-label">Yesterday</p>
|
||||||
|
<ul class="done-list yesterday-list">
|
||||||
|
{#each yesterdays as c, i}
|
||||||
|
<li class="done-item" class:odd={i % 2 === 1}>
|
||||||
|
<span class="done-icon">{@html checkCircleIcon}</span>
|
||||||
|
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
|
||||||
|
<span class="yesterday-tag">yesterday</span>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/revoke"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async (args) => handleResult(args);
|
||||||
|
}}
|
||||||
|
class="revoke-form"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={c.id} />
|
||||||
|
<button type="submit" class="revoke-btn" title="Revoke"
|
||||||
|
>{@html revokeIcon}</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1095,7 +1237,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}>
|
<Card
|
||||||
|
cols={1}
|
||||||
|
title="Claims"
|
||||||
|
accent={hasClaimableRewards ? '#f97316' : undefined}
|
||||||
|
class={hasClaimableRewards ? 'claims-card' : ''}
|
||||||
|
>
|
||||||
{#if claimableRewards().length === 0}
|
{#if claimableRewards().length === 0}
|
||||||
<div class="claims-empty">
|
<div class="claims-empty">
|
||||||
{@html checkCircleIcon}<span>No outstanding claims</span>
|
{@html checkCircleIcon}<span>No outstanding claims</span>
|
||||||
@@ -1234,7 +1381,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showCountdown}
|
{#if showCountdown}
|
||||||
<div class="payday-countdown">
|
<div class="payday-countdown">
|
||||||
<div class="pd-icon">{@html moneyIcon}</div>
|
<div class="pd-icon"><PiggyBank size={44} strokeWidth={2} /></div>
|
||||||
<div class="pd-body">
|
<div class="pd-body">
|
||||||
<p class="pd-title">Payday is today!</p>
|
<p class="pd-title">Payday is today!</p>
|
||||||
<p class="pd-num">
|
<p class="pd-num">
|
||||||
@@ -1248,14 +1395,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if paydayParty}
|
||||||
|
<div class="payday-party" role="status" onclick={() => (paydayParty = false)}>
|
||||||
|
<span class="party-cat">{partyEmoji}</span>
|
||||||
|
<div class="party-body">
|
||||||
|
<p class="party-title">PAYDAY!!</p>
|
||||||
|
<p class="party-sub">Your cash is ready — go collect it! 🎉</p>
|
||||||
|
</div>
|
||||||
|
<span class="party-stars">🌈⭐🌈</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<svg
|
<svg class="hero-bg" viewBox="0 0 700 120" preserveAspectRatio="none" aria-hidden="true">
|
||||||
class="hero-bg"
|
|
||||||
viewBox="0 0 700 120"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline
|
<polyline
|
||||||
points={heroSpark.line}
|
points={heroSpark.line}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1405,6 +1557,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
|
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if inYesterday}
|
||||||
|
<button class="hero-now hero-yesterday-on" onclick={() => (viewDate = 'today')}>
|
||||||
|
<span>← Back to today</span>
|
||||||
|
</button>
|
||||||
|
{:else if yesterdayAvailable}
|
||||||
|
<button class="hero-now hero-yesterday" onclick={() => (viewDate = 'yesterday')}>
|
||||||
|
<span>Forgot to mark a chore yesterday?</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="hero-nav">
|
<nav class="hero-nav">
|
||||||
@@ -1423,7 +1584,7 @@
|
|||||||
<!-- WEEK-TO-DATE TILES -->
|
<!-- WEEK-TO-DATE TILES -->
|
||||||
<div class="tiles">
|
<div class="tiles">
|
||||||
<div class="tile tile-cash">
|
<div class="tile tile-cash">
|
||||||
<span class="tile-val">£{weekChoreCash.toFixed(2)}</span>
|
<span class="tile-val">£{weekCashTotal.toFixed(2)}</span>
|
||||||
<span class="tile-lbl">cash this week</span>
|
<span class="tile-lbl">cash this week</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile tile-pts">
|
<div class="tile tile-pts">
|
||||||
@@ -1531,6 +1692,16 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if inYesterday}
|
||||||
|
<div class="yesterday-banner">
|
||||||
|
<span
|
||||||
|
>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span
|
||||||
|
>
|
||||||
|
<button class="yesterday-back" onclick={() => (viewDate = 'today')}
|
||||||
|
>Back to today →</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
<div class="column col-daily">
|
<div class="column col-daily">
|
||||||
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
|
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
|
||||||
@@ -1538,7 +1709,11 @@
|
|||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each dailyPending as chore}
|
{#each dailyPending as chore}
|
||||||
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
<button
|
||||||
|
class="chore"
|
||||||
|
onclick={() => toggle(chore)}
|
||||||
|
disabled={togglingIds.has(chore.id)}
|
||||||
|
>
|
||||||
<span class="checkbox">{@html circleIcon}</span>
|
<span class="checkbox">{@html circleIcon}</span>
|
||||||
<span class="chore-name">{choreName(chore)}</span>
|
<span class="chore-name">{choreName(chore)}</span>
|
||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
@@ -1555,7 +1730,11 @@
|
|||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each weeklyPending as chore}
|
{#each weeklyPending as chore}
|
||||||
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
<button
|
||||||
|
class="chore"
|
||||||
|
onclick={() => toggle(chore)}
|
||||||
|
disabled={togglingIds.has(chore.id)}
|
||||||
|
>
|
||||||
<span class="checkbox">{@html circleIcon}</span>
|
<span class="checkbox">{@html circleIcon}</span>
|
||||||
<span class="chore-name">{choreName(chore)}</span>
|
<span class="chore-name">{choreName(chore)}</span>
|
||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
@@ -1627,6 +1806,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
<button class="add-todo-cta" onclick={() => (showTodoModal = true)}>
|
||||||
|
<span aria-hidden="true">+</span> Add todo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column col-done">
|
<div class="column col-done">
|
||||||
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
|
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
|
||||||
@@ -1636,7 +1818,15 @@
|
|||||||
{#each completedToday as c}
|
{#each completedToday as c}
|
||||||
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
|
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
|
||||||
{#if chore}
|
{#if chore}
|
||||||
<button class="chore done" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
<!-- Yesterday view is mark-only: un-doing a backdated
|
||||||
|
completion would toggle against this week's window
|
||||||
|
and create confusing duplicates, so undo stays on today. -->
|
||||||
|
<button
|
||||||
|
class="chore done"
|
||||||
|
onclick={() => !inYesterday && toggle(chore)}
|
||||||
|
disabled={togglingIds.has(chore.id) || inYesterday}
|
||||||
|
title={inYesterday ? 'Go back to today to undo' : undefined}
|
||||||
|
>
|
||||||
<span class="checkbox">{@html checkCircleIcon}</span>
|
<span class="checkbox">{@html checkCircleIcon}</span>
|
||||||
<span class="chore-name">{choreName(chore)}</span>
|
<span class="chore-name">{choreName(chore)}</span>
|
||||||
{#if chore.type === 'emoji'}
|
{#if chore.type === 'emoji'}
|
||||||
@@ -1653,6 +1843,45 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- WALLET / CLAIMS -->
|
<!-- WALLET / CLAIMS -->
|
||||||
|
{#if showTodoModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
onclick={() => !todoSaving && (showTodoModal = false)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && !todoSaving && (showTodoModal = false)}
|
||||||
|
>
|
||||||
|
<div class="modal-card" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>🎯 New todo</h3>
|
||||||
|
<p class="modal-sub">Something you want to get done this week? Add it here.</p>
|
||||||
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="text"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="e.g. Tidy my desk"
|
||||||
|
bind:value={newTodoName}
|
||||||
|
disabled={todoSaving}
|
||||||
|
autofocus
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && submitTodo()}
|
||||||
|
/>
|
||||||
|
{#if todoError}
|
||||||
|
<p class="modal-error">{todoError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-cancel"
|
||||||
|
onclick={() => (showTodoModal = false)}
|
||||||
|
disabled={todoSaving}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-add"
|
||||||
|
onclick={submitTodo}
|
||||||
|
disabled={!newTodoName.trim() || todoSaving}
|
||||||
|
>{todoSaving ? 'Adding…' : 'Add todo'}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="wallet">
|
<div class="wallet">
|
||||||
<div class="wallet-top">
|
<div class="wallet-top">
|
||||||
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
||||||
@@ -2195,6 +2424,52 @@
|
|||||||
.hero-now:hover span {
|
.hero-now:hover span {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
.hero-now.hero-yesterday span {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hero-now.hero-yesterday:hover span {
|
||||||
|
background: rgba(255, 255, 255, 0.32);
|
||||||
|
}
|
||||||
|
.yesterday-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
color: #92400e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.yesterday-back {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.yesterday-back:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.yesterday-tag {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #b45309;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.hero-top {
|
.hero-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2809,6 +3084,93 @@
|
|||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.add-todo-cta {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border: 1.5px dashed #c7d2fe;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add-todo-cta:hover {
|
||||||
|
background: #e0e7ff;
|
||||||
|
border-color: #818cf8;
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.modal-card h3 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.modal-sub {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.modal-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1.5px solid #d1d5db;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.modal-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px #e0e7ff;
|
||||||
|
}
|
||||||
|
.modal-error {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.modal-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.modal-cancel {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.modal-add {
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.todo-main {
|
.todo-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3050,12 +3412,14 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 1rem 1.2rem;
|
padding: 1rem 1.2rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||||
color: #fff;
|
color: #451a03;
|
||||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
|
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
|
||||||
}
|
}
|
||||||
.pd-icon {
|
.pd-icon {
|
||||||
font-size: 1.8rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.pd-body {
|
.pd-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3080,6 +3444,91 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Payday party: nyan-style colour-cycling celebration ── */
|
||||||
|
.payday-party {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#ef4444,
|
||||||
|
#f97316,
|
||||||
|
#eab308,
|
||||||
|
#22c55e,
|
||||||
|
#3b82f6,
|
||||||
|
#a855f7,
|
||||||
|
#ec4899,
|
||||||
|
#ef4444
|
||||||
|
);
|
||||||
|
background-size: 300% 100%;
|
||||||
|
animation:
|
||||||
|
nyan-slide 3s linear infinite,
|
||||||
|
party-pop 0.45s ease-out;
|
||||||
|
box-shadow: 0 8px 24px rgba(168, 85, 247, 0.45);
|
||||||
|
}
|
||||||
|
@keyframes nyan-slide {
|
||||||
|
to {
|
||||||
|
background-position: 300% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes party-pop {
|
||||||
|
from {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.party-cat {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
animation: party-bounce 0.55s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes party-bounce {
|
||||||
|
from {
|
||||||
|
transform: translateY(-4px) rotate(-10deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(4px) rotate(10deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.party-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.party-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
animation: party-wiggle 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes party-wiggle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.party-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.party-stars {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
animation: party-bounce 0.55s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
|
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
|
||||||
rules (same specificity, source order decides). */
|
rules (same specificity, source order decides). */
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ export const actions = {
|
|||||||
const startDate = fd.get('startDate') as string;
|
const startDate = fd.get('startDate') as string;
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
if (type === 'emoji') {
|
if (type === 'emoji') {
|
||||||
data.value = 0;
|
// 1, not 0: PB treats 0 as blank on the required `value` field.
|
||||||
|
data.value = 1;
|
||||||
data.emoji = (fd.get('emoji') as string) || '🎉';
|
data.emoji = (fd.get('emoji') as string) || '🎉';
|
||||||
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
||||||
data.customName = customName || undefined;
|
data.customName = customName || undefined;
|
||||||
@@ -160,7 +161,7 @@ export const actions = {
|
|||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
const type = fd.get('type') as string;
|
const type = fd.get('type') as string;
|
||||||
const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0);
|
const value = type === 'emoji' ? 1 : (parseFloat(fd.get('value') as string) || 0);
|
||||||
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
|
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
|
||||||
const todoStart = fd.get('todoStart') as string;
|
const todoStart = fd.get('todoStart') as string;
|
||||||
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export async function load(event) {
|
|||||||
pattern: me.pattern || '',
|
pattern: me.pattern || '',
|
||||||
themeSize: me.themeSize || '',
|
themeSize: me.themeSize || '',
|
||||||
themeOpacity: me.themeOpacity || '',
|
themeOpacity: me.themeOpacity || '',
|
||||||
|
partyEmoji: me.partyEmoji || '',
|
||||||
email: me.email || '',
|
email: me.email || '',
|
||||||
hasPin: false,
|
hasPin: false,
|
||||||
session: true
|
session: true
|
||||||
@@ -94,6 +95,7 @@ export async function load(event) {
|
|||||||
pattern: session.pattern || '',
|
pattern: session.pattern || '',
|
||||||
themeSize: session.themeSize || '',
|
themeSize: session.themeSize || '',
|
||||||
themeOpacity: session.themeOpacity || '',
|
themeOpacity: session.themeOpacity || '',
|
||||||
|
partyEmoji: session.partyEmoji || '',
|
||||||
email: '',
|
email: '',
|
||||||
hasPin: await hasPin(session.id).catch(() => false),
|
hasPin: await hasPin(session.id).catch(() => false),
|
||||||
session: true
|
session: true
|
||||||
@@ -112,6 +114,7 @@ export const actions = {
|
|||||||
const pattern = (fd.get('pattern') as string) || '';
|
const pattern = (fd.get('pattern') as string) || '';
|
||||||
const themeSize = (fd.get('themeSize') as string) || '';
|
const themeSize = (fd.get('themeSize') as string) || '';
|
||||||
const themeOpacity = (fd.get('themeOpacity') as string) || '';
|
const themeOpacity = (fd.get('themeOpacity') as string) || '';
|
||||||
|
const partyEmoji = String(fd.get('partyEmoji') || '').trim().slice(0, 8);
|
||||||
|
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const famId = session.famId;
|
const famId = session.famId;
|
||||||
@@ -124,6 +127,7 @@ export const actions = {
|
|||||||
data.pattern = pattern;
|
data.pattern = pattern;
|
||||||
data.themeSize = themeSize;
|
data.themeSize = themeSize;
|
||||||
data.themeOpacity = themeOpacity;
|
data.themeOpacity = themeOpacity;
|
||||||
|
if (partyEmoji) data.partyEmoji = partyEmoji;
|
||||||
data.email = email || '';
|
data.email = email || '';
|
||||||
const me = await s.fam.updateProfile(famId, data);
|
const me = await s.fam.updateProfile(famId, data);
|
||||||
return {
|
return {
|
||||||
@@ -133,6 +137,7 @@ export const actions = {
|
|||||||
pattern: me.pattern,
|
pattern: me.pattern,
|
||||||
themeSize: me.themeSize,
|
themeSize: me.themeSize,
|
||||||
themeOpacity: me.themeOpacity,
|
themeOpacity: me.themeOpacity,
|
||||||
|
partyEmoji: me.partyEmoji,
|
||||||
email: me.email
|
email: me.email
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -148,6 +153,7 @@ export const actions = {
|
|||||||
data.pattern = pattern;
|
data.pattern = pattern;
|
||||||
data.themeSize = themeSize;
|
data.themeSize = themeSize;
|
||||||
data.themeOpacity = themeOpacity;
|
data.themeOpacity = themeOpacity;
|
||||||
|
if (partyEmoji) data.partyEmoji = partyEmoji;
|
||||||
const me = await s.fam.updateProfile(famId, data);
|
const me = await s.fam.updateProfile(famId, data);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -156,6 +162,7 @@ export const actions = {
|
|||||||
pattern: me.pattern,
|
pattern: me.pattern,
|
||||||
themeSize: me.themeSize,
|
themeSize: me.themeSize,
|
||||||
themeOpacity: me.themeOpacity,
|
themeOpacity: me.themeOpacity,
|
||||||
|
partyEmoji: me.partyEmoji,
|
||||||
email: ''
|
email: ''
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -70,6 +70,14 @@
|
|||||||
'#8b5cf6',
|
'#8b5cf6',
|
||||||
'#14b8a6'
|
'#14b8a6'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Payday-party mascot — the emoji that leads the child's payday celebration.
|
||||||
|
let memberEmoji = $state(data.partyEmoji || '🐖');
|
||||||
|
const partyEmojis = [
|
||||||
|
'🐖', '🐱', '🐶', '🦄', '🐸', '🐵', '🦊', '🐼',
|
||||||
|
'🦁', '🐯', '🐰', '🐨', '🐷', '🐮', '🦖', '🐙',
|
||||||
|
'🚀', '🌈', '⭐', '🎮', '⚽', '🍕', '🍦', '🎉'
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero />
|
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero />
|
||||||
@@ -145,6 +153,28 @@
|
|||||||
<input type="hidden" name="color" value={memberColor} />
|
<input type="hidden" name="color" value={memberColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||||
|
Party emoji — leads your payday celebration 🎉
|
||||||
|
</label>
|
||||||
|
<div style="display:flex;gap:0.3rem;flex-wrap:wrap">
|
||||||
|
{#each partyEmojis as e}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (memberEmoji = e)}
|
||||||
|
aria-label="Pick {e}"
|
||||||
|
style="font-size:1.5rem;line-height:1;padding:0.25rem;border-radius:8px;border:{e ===
|
||||||
|
memberEmoji
|
||||||
|
? '2px solid #111827'
|
||||||
|
: '2px solid transparent'};background:{e === memberEmoji
|
||||||
|
? '#f3f4f6'
|
||||||
|
: 'transparent'};cursor:pointer"
|
||||||
|
>{e}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="partyEmoji" value={memberEmoji} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if data.role === 'parent'}
|
{#if data.role === 'parent'}
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
CardGrid,
|
CardGrid,
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
|
SaveButton,
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
NoticeDialog
|
NoticeDialog
|
||||||
@@ -80,9 +81,26 @@
|
|||||||
detectedTz = '';
|
detectedTz = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 'auto' resolves to the SERVER's timezone (UTC in prod), not the
|
||||||
|
// family's — so during BST everything lands an hour late. Pre-select
|
||||||
|
// the admin's own zone so saving stores a real IANA zone (DST-aware).
|
||||||
|
if ((timezone === 'auto' || !timezone) && detectedTz && timezoneOptions.includes(detectedTz)) {
|
||||||
|
timezone = detectedTz;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let qrDataUrl = $state('');
|
let qrDataUrl = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
|
||||||
|
// Save-button success feedback: each save form holds a SaveButton ref and
|
||||||
|
// flashes it green for 4s from its use:enhance callback on success.
|
||||||
|
type Flasher = { flash: () => void } | null;
|
||||||
|
let renameBtn: Flasher = $state(null);
|
||||||
|
let paydayBtn: Flasher = $state(null);
|
||||||
|
let seasonBtn: Flasher = $state(null);
|
||||||
|
let lockBtn: Flasher = $state(null);
|
||||||
|
let addMemberBtn: Flasher = $state(null);
|
||||||
|
let inviteBtn: Flasher = $state(null);
|
||||||
|
let applyCodeBtn: Flasher = $state(null);
|
||||||
let parentInviteName = $state('');
|
let parentInviteName = $state('');
|
||||||
let parentInviteEmail = $state('');
|
let parentInviteEmail = $state('');
|
||||||
|
|
||||||
@@ -147,6 +165,7 @@
|
|||||||
if (result.type === 'success') {
|
if (result.type === 'success') {
|
||||||
const d = result.data as { ok?: boolean; error?: string } | undefined;
|
const d = result.data as { ok?: boolean; error?: string } | undefined;
|
||||||
if (d?.ok) {
|
if (d?.ok) {
|
||||||
|
inviteBtn?.flash();
|
||||||
notices.success(
|
notices.success(
|
||||||
'Invite sent',
|
'Invite sent',
|
||||||
`Email sent to ${parentInviteEmail} with a join link and code.`
|
`Email sent to ${parentInviteEmail} with a join link and code.`
|
||||||
@@ -203,7 +222,16 @@
|
|||||||
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||||||
working.
|
working.
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="?/renameFam" use:enhance>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/renameFam"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') renameBtn?.flash();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label class="field-label" for="fam-name">Display name</label>
|
<label class="field-label" for="fam-name">Display name</label>
|
||||||
<input
|
<input
|
||||||
id="fam-name"
|
id="fam-name"
|
||||||
@@ -212,7 +240,7 @@
|
|||||||
placeholder={fam?.name || 'Family name'}
|
placeholder={fam?.name || 'Family name'}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm">Rename</Button>
|
<SaveButton bind:this={renameBtn} label="Rename" />
|
||||||
</form>
|
</form>
|
||||||
{#if fam?.slug}
|
{#if fam?.slug}
|
||||||
<p class="hint slug-line">
|
<p class="hint slug-line">
|
||||||
@@ -228,6 +256,7 @@
|
|||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result }) => {
|
return async ({ result }) => {
|
||||||
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
||||||
|
else if (result.type === 'success') paydayBtn?.flash();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="payday-form"
|
class="payday-form"
|
||||||
@@ -259,11 +288,12 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm">Set payday</Button>
|
<SaveButton bind:this={paydayBtn} label="Set payday" />
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto
|
Payday: the week starts on this day and weekly earnings are settled at this time. Pick
|
||||||
timezone follows each device.
|
your city so payday lands at the right local time — Auto uses the server's timezone
|
||||||
|
(UTC), which is an hour out during daylight saving.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Seasons" cols={1}>
|
<Card title="Seasons" cols={1}>
|
||||||
@@ -271,7 +301,17 @@
|
|||||||
Group chores into seasons. Tick a season on to make it available for assignment; untick to
|
Group chores into seasons. Tick a season on to make it available for assignment; untick to
|
||||||
disable it.
|
disable it.
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/createSeason"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') seasonBtn?.flash();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="season-form"
|
||||||
|
>
|
||||||
<label class="field-label" for="season-name">New season</label>
|
<label class="field-label" for="season-name">New season</label>
|
||||||
<input id="season-name" name="name" placeholder="Season name" required />
|
<input id="season-name" name="name" placeholder="Season name" required />
|
||||||
<div class="season-color-row">
|
<div class="season-color-row">
|
||||||
@@ -290,7 +330,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" value={seasonColor} />
|
<input type="hidden" name="color" value={seasonColor} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm">Add</Button>
|
<SaveButton bind:this={seasonBtn} label="Add" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ul class="season-list">
|
<ul class="season-list">
|
||||||
@@ -378,8 +418,10 @@
|
|||||||
return async ({ result }) => {
|
return async ({ result }) => {
|
||||||
if (result.type !== 'success') return;
|
if (result.type !== 'success') return;
|
||||||
const d = result.data as any;
|
const d = result.data as any;
|
||||||
if (d?.ok) lockMins = String(d.lockMins);
|
if (d?.ok) {
|
||||||
else if (d?.error) alert(d.error);
|
lockMins = String(d.lockMins);
|
||||||
|
lockBtn?.flash();
|
||||||
|
} else if (d?.error) alert(d.error);
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -388,7 +430,7 @@
|
|||||||
<option value="2">After 2 minutes of no use</option>
|
<option value="2">After 2 minutes of no use</option>
|
||||||
<option value="10">After 10 minutes of no use</option>
|
<option value="10">After 10 minutes of no use</option>
|
||||||
</select>
|
</select>
|
||||||
<Button type="submit" size="sm">Save</Button>
|
<SaveButton bind:this={lockBtn} label="Save" />
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
@@ -398,7 +440,16 @@
|
|||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card title="New member" cols={1}>
|
<Card title="New member" cols={1}>
|
||||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||||
<form method="POST" action="?/addMember" use:enhance>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addMember"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') addMemberBtn?.flash();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label class="field-label" for="new-child">New child</label>
|
<label class="field-label" for="new-child">New child</label>
|
||||||
<input
|
<input
|
||||||
id="new-child"
|
id="new-child"
|
||||||
@@ -407,7 +458,7 @@
|
|||||||
placeholder="Child name"
|
placeholder="Child name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm">Add child</Button>
|
<SaveButton bind:this={addMemberBtn} label="Add child" />
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -624,7 +675,7 @@
|
|||||||
placeholder="parent@example.com"
|
placeholder="parent@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm">Send invite</Button>
|
<SaveButton bind:this={inviteBtn} label="Send invite" />
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">They'll receive a link with a code to set up their own password.</p>
|
<p class="hint">They'll receive a link with a code to set up their own password.</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -697,6 +748,7 @@
|
|||||||
const d = result.data as { error?: string; ok?: boolean };
|
const d = result.data as { error?: string; ok?: boolean };
|
||||||
accessMsg = d.error || 'Code applied — access enabled.';
|
accessMsg = d.error || 'Code applied — access enabled.';
|
||||||
accessCodeInput = '';
|
accessCodeInput = '';
|
||||||
|
if (d.ok && !d.error) applyCodeBtn?.flash();
|
||||||
}
|
}
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
@@ -708,7 +760,7 @@
|
|||||||
placeholder="Enter access code"
|
placeholder="Enter access code"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
<SaveButton bind:this={applyCodeBtn} label="Apply code" />
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{#if accessMsg}
|
{#if accessMsg}
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
|
|||||||
|
|
||||||
export async function POST(event: RequestEvent) {
|
export async function POST(event: RequestEvent) {
|
||||||
const u = event.locals.user;
|
const u = event.locals.user;
|
||||||
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
if (!u || !event.locals.pbToken) {
|
||||||
|
console.error(`[diag] toggle 401 role=${u?.role} hasToken=${!!event.locals.pbToken}`);
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
const pb = createPbClient(event.locals.pbToken);
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
const body = await event.request.json().catch(() => ({}));
|
const body = await event.request.json().catch(() => ({}));
|
||||||
try {
|
try {
|
||||||
const s = createServices(pb, { id: u.id, role: u.role });
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
return json(await s.completions.toggle(u.famId, body));
|
const out = await s.completions.toggle(u.famId, body);
|
||||||
|
console.log(
|
||||||
|
`[diag] toggle ok member=${u.id} chore=${body.assignedChoreId} date=${body.date} completed=${(out as any)?.completed}`
|
||||||
|
);
|
||||||
|
return json(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle 400 member=${u.id} role=${u.role} fam=${u.famId} chore=${body.assignedChoreId} date=${body.date} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// TEMP diagnostic for the recurring prod CHORE_GONE. Compares what the client
|
||||||
|
// claims against server truth from both angles (superuser getOne — the toggle
|
||||||
|
// path — and the user's own list read — the kanban path). Authenticated,
|
||||||
|
// own-fam only, no tokens ever logged or returned.
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const assignedChoreId = event.url.searchParams.get('assignedChoreId') || '';
|
||||||
|
|
||||||
|
let suChore: any = null;
|
||||||
|
let suError = '';
|
||||||
|
if (assignedChoreId) {
|
||||||
|
try {
|
||||||
|
suChore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
suError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let userSeesIt: boolean | null = null;
|
||||||
|
let userListError = '';
|
||||||
|
try {
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const list = await pb
|
||||||
|
.collection('assigned_chores')
|
||||||
|
.getFullList({ filter: `famId = '${u.famId}'` });
|
||||||
|
if (assignedChoreId) userSeesIt = list.some((a: any) => a.id === assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
userListError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
session: { id: u.id, role: u.role, famId: u.famId },
|
||||||
|
asked: assignedChoreId,
|
||||||
|
superuser: suChore
|
||||||
|
? {
|
||||||
|
found: true,
|
||||||
|
famId: suChore.famId,
|
||||||
|
memberId: suChore.memberId,
|
||||||
|
isTodo: !!suChore.isTodo,
|
||||||
|
famMatch: suChore.famId === u.famId
|
||||||
|
}
|
||||||
|
: { found: false, error: suError },
|
||||||
|
userList: { seesIt: userSeesIt, error: userListError }
|
||||||
|
};
|
||||||
|
console.log(`[diag] debug-chore ${JSON.stringify(out)}`);
|
||||||
|
return json(out);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone';
|
||||||
|
|
||||||
|
// Kids can create todos for THEMSELVES only. Writes go through the superuser
|
||||||
|
// client because `assigned_chores.createRule` is parent-only — but every
|
||||||
|
// privileged field is forced server-side: memberId = session user, emoji type
|
||||||
|
// with zero value (so nobody can mint cash/points for themselves), weekly
|
||||||
|
// frequency, this-week start/completeBy (so it expires and purges naturally).
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u) {
|
||||||
|
console.error('[diag] todos 401 (no session)');
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
||||||
|
if (!name) throw error(400, 'Give your todo a name');
|
||||||
|
|
||||||
|
let payday = 1;
|
||||||
|
let tz = 'UTC';
|
||||||
|
try {
|
||||||
|
const fam: any = await pbAdmin.getOne('fams', u.famId);
|
||||||
|
if (fam.payday !== undefined && fam.payday !== null) payday = Number(fam.payday);
|
||||||
|
tz = resolveTz(fam.timezone || 'auto');
|
||||||
|
} catch {
|
||||||
|
tz = resolveTz('auto');
|
||||||
|
}
|
||||||
|
const today = todayInTz(tz);
|
||||||
|
const completeBy = addDaysStr(weekStart(payday, tz), 6);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await pbAdmin.create('assigned_chores', {
|
||||||
|
famId: u.famId,
|
||||||
|
memberId: u.id,
|
||||||
|
frequency: 'weekly',
|
||||||
|
type: 'emoji',
|
||||||
|
// Must be 1, not 0: PB treats 0 as blank on the required `value`
|
||||||
|
// number field (400 validation_required). Value is meaningless for
|
||||||
|
// emoji todos — never summed, never displayed, never rewarded.
|
||||||
|
value: 1,
|
||||||
|
customName: name,
|
||||||
|
isTodo: true,
|
||||||
|
emoji: '🎯',
|
||||||
|
startDate: today,
|
||||||
|
completeBy
|
||||||
|
});
|
||||||
|
console.log(`[diag] todo ok member=${u.id} fam=${u.famId} todo=${record.id} name=${name}`);
|
||||||
|
return json({ record });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] todo 400 member=${u.id} role=${u.role} fam=${u.famId} name=${name} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
|
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,21 @@ export const actions = {
|
|||||||
// An explicit email/password login supersedes kid mode on a shared device.
|
// An explicit email/password login supersedes kid mode on a shared device.
|
||||||
clearActiveChild(event.cookies);
|
clearActiveChild(event.cookies);
|
||||||
|
|
||||||
const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
|
// Fam lookup with retries: right after a deploy/rebuild PB can blip and
|
||||||
const famSlug = fam?.slug || user.famId;
|
// a single failed fetch falls back to the raw fam ID in the URL
|
||||||
|
// (/{famId}/...), which then persists via bookmarks/shortcuts.
|
||||||
|
let fam: any = null;
|
||||||
|
for (let i = 0; i < 4 && !fam; i++) {
|
||||||
|
if (i > 0) await new Promise((r) => setTimeout(r, 500 * i));
|
||||||
|
fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!fam) {
|
||||||
|
return fail(503, {
|
||||||
|
error: 'Family data is briefly unavailable (server restarting). Please try again.',
|
||||||
|
email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const famSlug = fam.slug;
|
||||||
|
|
||||||
// Parents (admins) land on the fam dashboard — no username in the URL.
|
// Parents (admins) land on the fam dashboard — no username in the URL.
|
||||||
if (user.role === 'parent') {
|
if (user.role === 'parent') {
|
||||||
|
|||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": "1.11.0"
|
"version": "1.11.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
createRule: RULE_FAM_WRITE,
|
createRule: RULE_FAM_WRITE,
|
||||||
updateRule: RULE_FAM_SCOPED,
|
updateRule: RULE_FAM_SCOPED,
|
||||||
deleteRule: RULE_FAM_SCOPED,
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
|
listRule: RULE_FAM_READ,
|
||||||
|
viewRule: RULE_FAM_READ,
|
||||||
},
|
},
|
||||||
)(ids),
|
)(ids),
|
||||||
},
|
},
|
||||||
@@ -418,6 +420,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
|
listRule: RULE_FAM_READ,
|
||||||
|
viewRule: RULE_FAM_READ,
|
||||||
},
|
},
|
||||||
)(ids),
|
)(ids),
|
||||||
},
|
},
|
||||||
@@ -438,6 +442,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
createRule: RULE_FAM_WRITE,
|
createRule: RULE_FAM_WRITE,
|
||||||
updateRule: RULE_FAM_SCOPED,
|
updateRule: RULE_FAM_SCOPED,
|
||||||
deleteRule: RULE_FAM_SCOPED,
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
|
listRule: RULE_FAM_READ,
|
||||||
|
viewRule: RULE_FAM_READ,
|
||||||
},
|
},
|
||||||
)(ids),
|
)(ids),
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-1
@@ -53,7 +53,11 @@ export function addDaysStr(dateStr: string, days: number): string {
|
|||||||
export function weekStart(payday: number, tz: string): string {
|
export function weekStart(payday: number, tz: string): string {
|
||||||
const today = todayInTz(tz);
|
const today = todayInTz(tz);
|
||||||
const wd = weekdayInTz(new Date(), tz);
|
const wd = weekdayInTz(new Date(), tz);
|
||||||
const back = (((wd - payday) % 7) + 7) % 7;
|
const sincePayday = (((wd - payday) % 7) + 7) % 7;
|
||||||
|
// The week CLOSES on payday, so it opens the day after: a payday itself
|
||||||
|
// belongs to the week ending that day (sincePayday 0 → back 6), e.g.
|
||||||
|
// payday Sunday → weeks run Monday..Sunday.
|
||||||
|
const back = sincePayday === 0 ? 6 : sincePayday - 1;
|
||||||
return addDaysStr(today, -back);
|
return addDaysStr(today, -back);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user