Compare commits
10 Commits
31da825e93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de3c8ca62 | |||
| d14a154dcb | |||
| 89ab955efc | |||
| edc8568cc4 | |||
| c070cc8053 | |||
| 8388868bee | |||
| 2a3f3836c0 | |||
| 6288a3d3f2 | |||
| adcb400f41 | |||
| a710e22f35 |
@@ -89,9 +89,15 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
setActiveChild(event.cookies, activeId);
|
||||
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
|
||||
// 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,8 +115,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (freshToken !== token) {
|
||||
setSessionCookie(event.cookies, freshToken);
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,11 @@ async function memberFetch<T = unknown>(
|
||||
}
|
||||
|
||||
export const memberApi = {
|
||||
async toggleCompletion(famId: string, assignedChoreId: string, date: string) {
|
||||
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date });
|
||||
async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
|
||||
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) {
|
||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
||||
|
||||
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
|
||||
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||
// operations that must bypass collection rules: creating child users, minting
|
||||
// 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;
|
||||
export async function createSuperClient() {
|
||||
if (superClient) return superClient;
|
||||
if (superClient?.authStore.isValid) return superClient;
|
||||
superClient = null;
|
||||
const pb = new PocketBase(PB_ENDPOINT);
|
||||
pb.autoCancellation(false);
|
||||
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
||||
@@ -40,29 +47,43 @@ export async function createSuperClient() {
|
||||
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
|
||||
// server PB access (authenticated user + superuser) lives in this one module.
|
||||
export const pbAdmin = {
|
||||
async getList(collection: string, filter = '') {
|
||||
const pb = await createSuperClient();
|
||||
return withSuperRetry((pb) => {
|
||||
const options: { filter?: string } = {};
|
||||
if (filter) options.filter = filter;
|
||||
return pb.collection(collection).getFullList(options);
|
||||
});
|
||||
},
|
||||
async getOne(collection: string, id: string) {
|
||||
const pb = await createSuperClient();
|
||||
return pb.collection(collection).getOne(id);
|
||||
return withSuperRetry((pb) => pb.collection(collection).getOne(id));
|
||||
},
|
||||
async create(collection: string, data: Record<string, unknown>) {
|
||||
const pb = await createSuperClient();
|
||||
return pb.collection(collection).create(data);
|
||||
return withSuperRetry((pb) => pb.collection(collection).create(data));
|
||||
},
|
||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||
const pb = await createSuperClient();
|
||||
return pb.collection(collection).update(id, data);
|
||||
return withSuperRetry((pb) => pb.collection(collection).update(id, data));
|
||||
},
|
||||
async remove(collection: string, id: string) {
|
||||
const pb = await createSuperClient();
|
||||
return pb.collection(collection).delete(id);
|
||||
return withSuperRetry((pb) => pb.collection(collection).delete(id));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,6 +106,15 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
||||
|
||||
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;
|
||||
|
||||
const rewardData = (memberId: string) => {
|
||||
@@ -128,135 +137,122 @@ export async function evaluateFam(pb: any, famId: string) {
|
||||
};
|
||||
};
|
||||
|
||||
if (cfg.target === 'individual') {
|
||||
const targetMembers = cfg.memberId
|
||||
? allMembers.filter((m: any) => m.id === cfg.memberId)
|
||||
: allMembers;
|
||||
for (const m of targetMembers) {
|
||||
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;
|
||||
// Completions counting toward one member (target-chore scoped when set).
|
||||
const completionsFor = (memberId: string) => {
|
||||
const memberTarget = targetChoreFor(cfg, memberId);
|
||||
let list = periodCompletions.filter((c: any) => c.memberId === memberId);
|
||||
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
|
||||
// Collaborative configs only honour the shared targetChoreId.
|
||||
if (cfg.target === 'collaborative' && cfg.targetChoreId)
|
||||
list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
||||
return list;
|
||||
};
|
||||
// Score for one member: percent of due chores, points value, or raw count.
|
||||
const scoreFor = (memberId: string): number => {
|
||||
const list = completionsFor(memberId);
|
||||
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 {
|
||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||
const due = dueByMember[memberId] || 0;
|
||||
return due > 0 ? Math.round((list.length / due) * 100) : 0;
|
||||
}
|
||||
return list.reduce((sum: number, c: any) => {
|
||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
current = memberCompletions.length;
|
||||
}
|
||||
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
||||
|
||||
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
|
||||
|
||||
if (memberReward && !achieved) {
|
||||
// Payday-gated (e.g. pocket money) and member-requested rewards
|
||||
// persist across week rollover until the parent issues them —
|
||||
// deleting them here gave parents only a payday-day window.
|
||||
if (memberReward.status !== 'claimed' && memberReward.status !== 'requested' && memberReward.claimable !== 'payday') {
|
||||
if (cfg.type === 'count') return list.length;
|
||||
return 0;
|
||||
};
|
||||
const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
||||
// Reconcile one member's window reward against their achieved state.
|
||||
// Returns true when a live window reward exists afterwards.
|
||||
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
|
||||
const live = windowRewards.filter((r: any) => r.memberId === memberId);
|
||||
if (!achieved) {
|
||||
for (const r of live) {
|
||||
if (!removable(r)) continue;
|
||||
try {
|
||||
await pb.collection('rewards').delete(memberReward.id);
|
||||
await pb.collection('rewards').delete(r.id);
|
||||
} catch {}
|
||||
windowRewards.splice(windowRewards.indexOf(r), 1);
|
||||
}
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (memberReward) continue;
|
||||
|
||||
if (achieved) {
|
||||
await pb.collection('rewards').create(rewardData(m.id));
|
||||
if (live.length > 0) return true;
|
||||
await pb.collection('rewards').create(rewardData(memberId));
|
||||
createdReward = true;
|
||||
}
|
||||
}
|
||||
} else if (cfg.target === 'collaborative') {
|
||||
const allMemberIds = allMembers.map((m: any) => m.id);
|
||||
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
||||
if (cfg.targetChoreId)
|
||||
teamCompletions = teamCompletions.filter(
|
||||
(c: any) => c.assignedChoreId === cfg.targetChoreId
|
||||
return true;
|
||||
};
|
||||
// Drop stale prior-window placeholders (unclaimed, non-gated) so they
|
||||
// can't pile up unseen; gated/requested/claimed rows persist by design.
|
||||
const purgeStale = async (memberId: string) => {
|
||||
const stale = existingRewards.filter(
|
||||
(r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
|
||||
);
|
||||
let total = 0;
|
||||
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' || r.status === 'requested' || r.claimable === 'payday') continue;
|
||||
for (const r of stale) {
|
||||
try {
|
||||
await pb.collection('rewards').delete(r.id);
|
||||
} catch {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if (achieved && existingRewards.length === 0) {
|
||||
for (const m of allMembers) {
|
||||
await pb.collection('rewards').create(rewardData(m.id));
|
||||
if (cfg.target === 'competitive') {
|
||||
// Winner-takes-all: a single window reward held by the top scorer.
|
||||
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;
|
||||
}
|
||||
// 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 {
|
||||
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);
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||
}, 0);
|
||||
}
|
||||
} else if (cfg.type === 'count') {
|
||||
current = memberCompletions.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' && existing.status !== 'requested' && existing.claimable !== 'payday') {
|
||||
try {
|
||||
await pb.collection('rewards').delete(existing.id);
|
||||
} catch {}
|
||||
teamTotal = team.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (winner && existingRewards.length === 0) {
|
||||
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||
createdReward = true;
|
||||
const members =
|
||||
cfg.target === 'individual' && cfg.memberId
|
||||
? allMembers.filter((m: any) => m.id === cfg.memberId)
|
||||
: 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,9 +45,19 @@ 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;
|
||||
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();
|
||||
}
|
||||
|
||||
// Chore existence is checked as superuser so a stale client ID (parent
|
||||
// re-saved the chores grid → old assigned rows deleted, new ids issued)
|
||||
@@ -57,10 +67,16 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
||||
let chore: any;
|
||||
try {
|
||||
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||
} catch {
|
||||
} 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;
|
||||
@@ -91,7 +107,14 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
||||
if (isClaimable && existing[0].memberId === memberId) {
|
||||
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 };
|
||||
}
|
||||
let rewardId: string | undefined;
|
||||
@@ -113,7 +136,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
||||
memberId,
|
||||
assignedChoreId,
|
||||
date,
|
||||
completedAt: new Date().toISOString(),
|
||||
completedAt,
|
||||
...(rewardId ? { rewardId } : {})
|
||||
});
|
||||
// Claim a shared one-off todo by setting memberId (recurring shared
|
||||
@@ -121,7 +144,11 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
||||
if (isClaimable) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -139,6 +166,10 @@ export async function revoke(pb: any, famId: string, completionId: string) {
|
||||
if (chore?.shared === true) {
|
||||
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 };
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function createServices(
|
||||
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
|
||||
},
|
||||
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),
|
||||
revoke: (famId: string, completionId: string) =>
|
||||
completionsSvc.revoke(pb, famId, completionId)
|
||||
|
||||
@@ -7,7 +7,7 @@ function resolveServerTz(tz?: string): string {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Config write via superuser: bonus_configs updates are parent-only.
|
||||
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible
|
||||
|
||||
@@ -321,7 +321,12 @@ class FamStore {
|
||||
// and from PB subscribe SSE for multi-user realtime.
|
||||
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
||||
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')
|
||||
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
||||
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
||||
|
||||
@@ -694,7 +694,7 @@
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: 4px solid #6366f1;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
background: #fafbff;
|
||||
}
|
||||
.col-member {
|
||||
@@ -709,6 +709,7 @@
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
@@ -741,8 +742,8 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin: 0.9rem 0 0.7rem;
|
||||
gap: 0.7rem;
|
||||
margin: 1.1rem 0 1.2rem;
|
||||
}
|
||||
.donut-wrap {
|
||||
position: relative;
|
||||
@@ -780,19 +781,20 @@
|
||||
.member-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.member-stats.compact .mstat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.76rem;
|
||||
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: 8px;
|
||||
padding: 0.28rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,18 @@
|
||||
(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 {
|
||||
const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
|
||||
@@ -254,6 +266,29 @@
|
||||
let todayChild = $derived(todayInTz(famTz));
|
||||
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) ───
|
||||
let nowMs = $state(Date.now());
|
||||
let eowFired = $state(false);
|
||||
@@ -309,6 +344,37 @@
|
||||
let weekEnd = $derived(addDays(weekStart, 6));
|
||||
|
||||
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(
|
||||
Math.max(
|
||||
0,
|
||||
@@ -379,10 +445,10 @@
|
||||
});
|
||||
|
||||
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(
|
||||
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)
|
||||
@@ -400,7 +466,7 @@
|
||||
return 'blue';
|
||||
}
|
||||
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(() => {
|
||||
@@ -432,9 +498,7 @@
|
||||
const counts: number[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStart, i);
|
||||
counts.push(
|
||||
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
|
||||
);
|
||||
counts.push(weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length);
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
@@ -799,7 +863,7 @@
|
||||
'December'
|
||||
];
|
||||
function headDate(): Date {
|
||||
return new Date(todayInTz(famTz) + 'T00:00:00');
|
||||
return new Date(activeDate + 'T00:00:00');
|
||||
}
|
||||
function headDay(): number {
|
||||
return headDate().getDate();
|
||||
@@ -873,7 +937,7 @@
|
||||
? c.assignedChoreId === chore.id &&
|
||||
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||||
(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-'));
|
||||
return optimistic || completions.find(match);
|
||||
}
|
||||
@@ -888,7 +952,14 @@
|
||||
if (togglingIds.has(chore.id)) return;
|
||||
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) {
|
||||
const existing = findCompletion(chore);
|
||||
if (existing) {
|
||||
@@ -902,15 +973,15 @@
|
||||
famId,
|
||||
memberId,
|
||||
assignedChoreId: chore.id,
|
||||
date: todayChild,
|
||||
completedAt: new Date().toISOString()
|
||||
date: stampDate,
|
||||
completedAt: stampAt
|
||||
} as Completion,
|
||||
'create'
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
if (optimistic) {
|
||||
famStore.applyRecord('completions', optimistic, 'delete');
|
||||
@@ -925,10 +996,18 @@
|
||||
if (optimistic) {
|
||||
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 {}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
showToast(
|
||||
/CHORE_GONE/.test(msg)
|
||||
? 'This chore was changed — list refreshed.'
|
||||
@@ -1013,6 +1092,7 @@
|
||||
<Card cols={1} title="Members">
|
||||
{#each parentMembers as m}
|
||||
{@const todays = todayCompletionsFor(m.id)}
|
||||
{@const yesterdays = yesterdayCompletionsFor(m.id)}
|
||||
{@const done = todays.length}
|
||||
{@const s = memberInSummary(m.id)}
|
||||
{@const pm = parentBonusConfigs.find(
|
||||
@@ -1067,6 +1147,31 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/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>
|
||||
{/each}
|
||||
</Card>
|
||||
@@ -1132,7 +1237,12 @@
|
||||
{/if}
|
||||
</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}
|
||||
<div class="claims-empty">
|
||||
{@html checkCircleIcon}<span>No outstanding claims</span>
|
||||
@@ -1297,12 +1407,7 @@
|
||||
{/if}
|
||||
<!-- HERO -->
|
||||
<header class="hero">
|
||||
<svg
|
||||
class="hero-bg"
|
||||
viewBox="0 0 700 120"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="hero-bg" viewBox="0 0 700 120" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline
|
||||
points={heroSpark.line}
|
||||
fill="none"
|
||||
@@ -1452,6 +1557,15 @@
|
||||
{:else}
|
||||
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
|
||||
{/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>
|
||||
|
||||
<nav class="hero-nav">
|
||||
@@ -1578,6 +1692,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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="column col-daily">
|
||||
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
|
||||
@@ -1585,7 +1709,11 @@
|
||||
<p class="empty">All done!</p>
|
||||
{:else}
|
||||
{#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="chore-name">{choreName(chore)}</span>
|
||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||
@@ -1602,7 +1730,11 @@
|
||||
<p class="empty">All done!</p>
|
||||
{:else}
|
||||
{#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="chore-name">{choreName(chore)}</span>
|
||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||
@@ -1674,6 +1806,9 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<button class="add-todo-cta" onclick={() => (showTodoModal = true)}>
|
||||
<span aria-hidden="true">+</span> Add todo
|
||||
</button>
|
||||
</div>
|
||||
<div class="column col-done">
|
||||
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
|
||||
@@ -1683,7 +1818,15 @@
|
||||
{#each completedToday as c}
|
||||
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
|
||||
{#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="chore-name">{choreName(chore)}</span>
|
||||
{#if chore.type === 'emoji'}
|
||||
@@ -1700,6 +1843,45 @@
|
||||
{/if}
|
||||
|
||||
<!-- 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-top">
|
||||
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
||||
@@ -2242,6 +2424,52 @@
|
||||
.hero-now:hover span {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2856,6 +3084,93 @@
|
||||
padding: 1px 5px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -116,7 +116,8 @@ export const actions = {
|
||||
const startDate = fd.get('startDate') as string;
|
||||
if (type) data.type = type;
|
||||
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) || '🎉';
|
||||
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
||||
data.customName = customName || undefined;
|
||||
@@ -160,7 +161,7 @@ export const actions = {
|
||||
const memberId = fd.get('memberId') as string;
|
||||
const name = fd.get('name') 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 todoStart = fd.get('todoStart') as string;
|
||||
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
||||
|
||||
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
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 body = await event.request.json().catch(() => ({}));
|
||||
try {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,5 +13,5 @@
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"version": "1.11.4"
|
||||
"version": "1.11.7"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user