added todos | payday time settings | refactor payment scheduling
This commit is contained in:
@@ -92,3 +92,21 @@
|
|||||||
- **Bug**: Both `join/[code]/+page.server.ts` and `join/[code]/[member]/+page.server.ts` called `setSessionCookie()` with `userId: memberId` (the child's PB record ID). This made `event.locals.session` truthy for children, causing `[username]/+page.server.ts` to enter the admin branch and call `hono.admin.*` endpoints. The proxy's `requireAdmin` checked `fam_admins` for the child's member ID (which doesn't exist) and returned 401.
|
- **Bug**: Both `join/[code]/+page.server.ts` and `join/[code]/[member]/+page.server.ts` called `setSessionCookie()` with `userId: memberId` (the child's PB record ID). This made `event.locals.session` truthy for children, causing `[username]/+page.server.ts` to enter the admin branch and call `hono.admin.*` endpoints. The proxy's `requireAdmin` checked `fam_admins` for the child's member ID (which doesn't exist) and returned 401.
|
||||||
- **Fix**: Removed `setSessionCookie()` from both join pages. Children only get a `device_token` cookie. The session cookie is only for email/password-authenticated parents, set by `/login` and `/signup`.
|
- **Fix**: Removed `setSessionCookie()` from both join pages. Children only get a `device_token` cookie. The session cookie is only for email/password-authenticated parents, set by `/login` and `/signup`.
|
||||||
- **Lesson**: Children must never get a session cookie. The auth table in AGENTS.md says "Member → device token, no expiry" — the code must match.
|
- **Lesson**: Children must never get a session cookie. The auth table in AGENTS.md says "Member → device token, no expiry" — the code must match.
|
||||||
|
|
||||||
|
### 2026-08-03 — Family Timezone Setting + Tz-Aware Week Math
|
||||||
|
|
||||||
|
- **Feature**: `fams.timezone` (IANA name or `"auto"`) added via `migrate.ts` 5d/5e (field + backfill `"auto"`). Exposed in settings Payday card (dropdown from `COMMON_TIMEZONES`, ~40 entries, + "Auto (detected)"). Set via `PATCH /api/admin/:famId/fam` alongside payday/paydayTime.
|
||||||
|
- **Shared module** `timezone.ts` (root, imported by both proxy and frontend): `resolveTz`, `dateStrInTz`, `weekdayInTz`, `todayInTz`, `addDaysStr` (pure UTC), `weekStart(payday, tz)`, `wallClockToUtc` (iterative 4-pass Intl, DST-safe), `COMMON_TIMEZONES`.
|
||||||
|
- **Bug fixed** ("5 days left on Monday"): old `mondayOf`/`addDays`/`daysLeft` used local `setDate` + `toISOString()`. On BST Sunday Aug 9 local midnight → UTC Aug 8, so Monday showed 5 days left instead of 6. Now the child page computes `weekStart`/`todayIso`/`daysLeft` via `weekStart(1, famTz)` + `addDaysStr` + `todayInTz`, giving 6. Verified: Mon Aug 3 → weekStart 2026-08-03, weekEnd 2026-08-09, daysLeft 6.
|
||||||
|
- **releaseWeek time gate**: now tz-aware. `weekStart(payday, tz)` for idempotency check; `target = new Date(wallClockToUtc(weekStartToday, paydayTime, tz))`. Verified: payday Mon 20:00 Europe/London (BST) → target `2026-08-03T19:00:00Z`; `auto` resolves to server tz. Returns `{settled:false, notYet:true, weekStart, target}` before the time.
|
||||||
|
- **Proxy threads `tz`** through weekly-summary, eow-preview, bonus-configs/progress, `evaluateFam`, tallies, manual trigger, complete-week, `releaseWeek`. `my-chores` returns `timezone`; child `+page.server.ts` passes it to `data.timezone`; parent passes `data.fam.timezone`.
|
||||||
|
- **Frontend child page**: `rawFamTz = data.timezone || data.fam?.timezone || 'auto'`, `famTz = resolveTz(rawFamTz)`. `todayChild`, `todayIso`, `mondayOf`, `addDays`, `isPaydayToday` (via `weekdayInTz`), `paydayTarget` (via `wallClockToUtc`), `today` all tz-aware. `mondayOf` now delegates to `tzWeekStart(1, famTz)`.
|
||||||
|
- **Smoke-tested live** (fam `v56f0f8o147kj1x`): GET fam returns timezone, PATCH sets Europe/London, time-gate returns correct target, `auto` resolves to server tz, settings page SSR shows the dropdown, child page 200. Fam restored to payday=0, paydayTime=18:00, timezone=auto, lastIssued=2026-08-02.
|
||||||
|
- **Typecheck**: proxy `tsc --noEmit` 28 errors (all pre-existing rootDir/`.ts`-import/`key: never`/implicit-any baseline); frontend `svelte-check` 9 errors (baseline; no new errors in edited files).
|
||||||
|
|
||||||
|
### 2026-08-04 — Terminology: "Payday" + Countdown to Settlement Day
|
||||||
|
|
||||||
|
- **Decision**: Standardize the user-facing term on **"payday"** for the weekly settlement event/day. "EOW" is ambiguous (window-close vs settlement day) and is now dropped from user-facing strings. Keep "week" for the Sun→Sat earning window. Internal identifiers (`eow*`, `simulateEow`, `eowPreview`, CSS `eow-*`) left as-is.
|
||||||
|
- **daysLeft now counts to settlement day**: `daysLeft` on the child dashboard counts to `weekStart + 7` (the next payday day) instead of `weekEnd = weekStart + 6`. So Tue Aug 4 with payday=Sun shows 5 days until payday. Hero label updated to "days until payday".
|
||||||
|
- **User-facing renames**: hero `days left` → `days until payday`; debug card `Simulate End-of-Week` → `Simulate Payday`; error `Failed to preview EOW` → `Failed to preview payday`; settings hint reworded to lead with "Payday:".
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ export const PB_EMAIL = "debug@famchamp.dev";
|
|||||||
export const PB_PASSWORD = "debug123";
|
export const PB_PASSWORD = "debug123";
|
||||||
export const DEBUG_RECORD_ID = "0747qjl16m6o529";
|
export const DEBUG_RECORD_ID = "0747qjl16m6o529";
|
||||||
export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`;
|
export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`;
|
||||||
//# sourceMappingURL=config.js.map
|
//# sourceMappingURL=config.js.map
|
||||||
|
|||||||
@@ -39,4 +39,7 @@ export const memberApi = {
|
|||||||
async requestAllRewards(token: string, famId: string) {
|
async requestAllRewards(token: string, famId: string) {
|
||||||
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
|
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
|
||||||
},
|
},
|
||||||
|
async payday(token: string, famId: string) {
|
||||||
|
return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,8 +24,17 @@ async function request(
|
|||||||
headers: headers || { 'Content-Type': 'application/json' },
|
headers: headers || { 'Content-Type': 'application/json' },
|
||||||
body: body ? JSON.stringify(body) : undefined
|
body: body ? JSON.stringify(body) : undefined
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const text = await res.text();
|
||||||
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
|
let data: any = {};
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.error || data?.message || `${method} ${path} failed (HTTP ${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +71,19 @@ export const hono = {
|
|||||||
async renameFam(event: RequestEvent, famId: string, name: string) {
|
async renameFam(event: RequestEvent, famId: string, name: string) {
|
||||||
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
|
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
|
||||||
},
|
},
|
||||||
async updatePayday(event: RequestEvent, famId: string, payday: number) {
|
async updatePayday(
|
||||||
return request('PATCH', `/api/admin/${famId}/fam`, { payday }, sessionHeaders(event));
|
event: RequestEvent,
|
||||||
|
famId: string,
|
||||||
|
payday: number,
|
||||||
|
paydayTime?: string,
|
||||||
|
timezone?: string
|
||||||
|
) {
|
||||||
|
return request(
|
||||||
|
'PATCH',
|
||||||
|
`/api/admin/${famId}/fam`,
|
||||||
|
{ payday, ...(paydayTime !== undefined ? { paydayTime } : {}), ...(timezone !== undefined ? { timezone } : {}) },
|
||||||
|
sessionHeaders(event)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async fam(event: RequestEvent, famId: string) {
|
async fam(event: RequestEvent, famId: string) {
|
||||||
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
|
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
|
||||||
@@ -216,6 +236,20 @@ export const hono = {
|
|||||||
},
|
},
|
||||||
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
|
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
|
||||||
return request(method, path, body, sessionHeaders(event));
|
return request(method, path, body, sessionHeaders(event));
|
||||||
|
},
|
||||||
|
async settings(event: RequestEvent, famId: string) {
|
||||||
|
return request('GET', `/api/admin/${famId}/settings`, undefined, sessionHeaders(event));
|
||||||
|
},
|
||||||
|
async updateSettings(event: RequestEvent, famId: string, data: Record<string, unknown>) {
|
||||||
|
return request('PATCH', `/api/admin/${famId}/settings`, data, sessionHeaders(event));
|
||||||
|
},
|
||||||
|
async eowPreview(event: RequestEvent, famId: string) {
|
||||||
|
return request(
|
||||||
|
'GET',
|
||||||
|
`/api/admin/${famId}/debug/eow-preview`,
|
||||||
|
undefined,
|
||||||
|
sessionHeaders(event)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type Frequency = 'daily' | 'weekly';
|
export type Frequency = 'daily' | 'weekly';
|
||||||
export type RewardType = 'points' | 'money';
|
export type RewardType = 'points' | 'money' | 'emoji';
|
||||||
export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
|
export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
|
||||||
export type BonusType = 'threshold' | 'count' | 'manual';
|
export type BonusType = 'threshold' | 'count' | 'manual';
|
||||||
export type BonusOccurrence = 'recurring' | 'once';
|
export type BonusOccurrence = 'recurring' | 'once';
|
||||||
@@ -43,6 +43,10 @@ export interface Fam {
|
|||||||
inviteCode: string;
|
inviteCode: string;
|
||||||
stripeCustomerId?: string;
|
stripeCustomerId?: string;
|
||||||
featureFlags: Record<string, boolean>;
|
featureFlags: Record<string, boolean>;
|
||||||
|
payday?: number;
|
||||||
|
paydayTime?: string;
|
||||||
|
timezone?: string;
|
||||||
|
lastIssued?: string;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
@@ -70,16 +74,19 @@ export interface ChoreTemplate {
|
|||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignedChore {
|
export interface AssignedChore {
|
||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
memberId: string;
|
memberId: string;
|
||||||
templateId: string;
|
templateId?: string;
|
||||||
frequency: Frequency;
|
frequency: Frequency;
|
||||||
type: RewardType;
|
type: RewardType;
|
||||||
value: number;
|
value: number;
|
||||||
customName?: string;
|
customName?: string;
|
||||||
seasonIds?: string[];
|
seasonIds?: string[];
|
||||||
|
isTodo?: boolean;
|
||||||
|
startDate?: string;
|
||||||
|
completeBy?: string;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
@@ -160,6 +167,7 @@ export interface Settings {
|
|||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
|
simulateEow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
|||||||
@@ -2,26 +2,48 @@ import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
|||||||
|
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||||
|
|
||||||
|
async function paydayCheck(famId: string, headers: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${HONO_URL}/api/fam/${famId}/payday`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Best-effort: never block render on the payday heartbeat.
|
||||||
|
await res.json().catch(() => null);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session || null;
|
const session = event.locals.session || null;
|
||||||
const isParent = session !== null;
|
const isParent = session !== null;
|
||||||
|
const deviceToken = event.cookies.get('device_token') || '';
|
||||||
|
|
||||||
let famId = '';
|
let famId = '';
|
||||||
|
|
||||||
if (isParent) {
|
if (isParent) {
|
||||||
famId = session.famId;
|
famId = session.famId;
|
||||||
} else {
|
await paydayCheck(famId, {
|
||||||
const deviceToken = event.cookies.get('device_token') || '';
|
'x-session-famid': session.famId,
|
||||||
if (deviceToken) {
|
'x-session-userid': session.userId
|
||||||
try {
|
});
|
||||||
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
} else if (deviceToken) {
|
||||||
headers: { 'x-device-token': deviceToken },
|
try {
|
||||||
});
|
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
||||||
if (res.ok) {
|
headers: { 'x-device-token': deviceToken }
|
||||||
const body = await res.json();
|
});
|
||||||
famId = body.famId || '';
|
if (res.ok) {
|
||||||
}
|
const body = await res.json();
|
||||||
} catch {}
|
famId = body.famId || '';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (famId) {
|
||||||
|
await paydayCheck(famId, {
|
||||||
|
'x-device-token': deviceToken,
|
||||||
|
'x-device-famid': famId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,17 @@ export async function load(event) {
|
|||||||
if (session.memberName && session.memberName !== username) {
|
if (session.memberName && session.memberName !== username) {
|
||||||
throw redirect(303, `/${famSlug}/${session.memberName}`);
|
throw redirect(303, `/${famSlug}/${session.memberName}`);
|
||||||
}
|
}
|
||||||
const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = await Promise.all([
|
const [
|
||||||
|
members,
|
||||||
|
templates,
|
||||||
|
assigned,
|
||||||
|
summary,
|
||||||
|
fam,
|
||||||
|
rewards,
|
||||||
|
bonusConfigs,
|
||||||
|
completions,
|
||||||
|
settings
|
||||||
|
] = await Promise.all([
|
||||||
hono.admin.list(event, 'members', famId),
|
hono.admin.list(event, 'members', famId),
|
||||||
hono.admin.list(event, 'chore-templates', famId),
|
hono.admin.list(event, 'chore-templates', famId),
|
||||||
hono.admin.list(event, 'assigned-chores', famId),
|
hono.admin.list(event, 'assigned-chores', famId),
|
||||||
@@ -25,28 +35,68 @@ export async function load(event) {
|
|||||||
hono.admin.rewards(event, famId),
|
hono.admin.rewards(event, famId),
|
||||||
hono.admin.bonusConfigs(event, famId),
|
hono.admin.bonusConfigs(event, famId),
|
||||||
hono.admin.completions(event, famId),
|
hono.admin.completions(event, famId),
|
||||||
|
hono.admin.settings(event, famId).catch(() => ({}))
|
||||||
]);
|
]);
|
||||||
return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions };
|
return {
|
||||||
|
role: 'parent',
|
||||||
|
members,
|
||||||
|
templates,
|
||||||
|
assigned,
|
||||||
|
summary,
|
||||||
|
fam,
|
||||||
|
famSlug,
|
||||||
|
rewards,
|
||||||
|
bonusConfigs,
|
||||||
|
completions,
|
||||||
|
settings
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Child (device token) → kanban
|
// Child (device token) → kanban
|
||||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
const deviceToken =
|
||||||
|
event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||||
const famSlug = event.params.fam;
|
const famSlug = event.params.fam;
|
||||||
const username = event.params.username;
|
const username = event.params.username;
|
||||||
|
|
||||||
if (!deviceToken) {
|
if (!deviceToken) {
|
||||||
return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
|
return {
|
||||||
|
role: 'child',
|
||||||
|
token: '',
|
||||||
|
memberId: '',
|
||||||
|
verified: false,
|
||||||
|
famId: '',
|
||||||
|
templates: [],
|
||||||
|
assigned: [],
|
||||||
|
completions: [],
|
||||||
|
rewards: [],
|
||||||
|
bonusConfigs: [],
|
||||||
|
tallies: {}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ deviceToken, famSlug }),
|
body: JSON.stringify({ deviceToken, famSlug })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.name !== username) {
|
if (!res.ok || data.name !== username) {
|
||||||
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
|
return {
|
||||||
|
role: 'child',
|
||||||
|
token: deviceToken,
|
||||||
|
memberId: '',
|
||||||
|
verified: false,
|
||||||
|
famId: '',
|
||||||
|
memberName: '',
|
||||||
|
memberColor: '',
|
||||||
|
templates: [],
|
||||||
|
assigned: [],
|
||||||
|
completions: [],
|
||||||
|
rewards: [],
|
||||||
|
bonusConfigs: [],
|
||||||
|
tallies: {}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let chores: any = {};
|
let chores: any = {};
|
||||||
@@ -68,13 +118,55 @@ export async function load(event) {
|
|||||||
rewards: chores.rewards || [],
|
rewards: chores.rewards || [],
|
||||||
bonusConfigs: chores.bonusConfigs || [],
|
bonusConfigs: chores.bonusConfigs || [],
|
||||||
tallies: chores.tallies || {},
|
tallies: chores.tallies || {},
|
||||||
|
payday: chores.payday,
|
||||||
|
paydayTime: chores.paydayTime || '18:00',
|
||||||
|
timezone: chores.timezone || 'auto'
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
|
return {
|
||||||
|
role: 'child',
|
||||||
|
token: deviceToken,
|
||||||
|
memberId: '',
|
||||||
|
verified: false,
|
||||||
|
famId: '',
|
||||||
|
templates: [],
|
||||||
|
assigned: [],
|
||||||
|
completions: [],
|
||||||
|
rewards: [],
|
||||||
|
bonusConfigs: [],
|
||||||
|
tallies: {},
|
||||||
|
payday: 1,
|
||||||
|
paydayTime: '18:00',
|
||||||
|
timezone: 'auto'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
setEow: async (event) => {
|
||||||
|
if (!event.locals.session) throw redirect(303, '/login');
|
||||||
|
const famId = event.locals.session.famId;
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const on = fd.get('on') === 'true';
|
||||||
|
try {
|
||||||
|
const result = await hono.admin.updateSettings(event, famId, { simulateEow: on });
|
||||||
|
return { simulateEow: result.simulateEow };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
previewEow: async (event) => {
|
||||||
|
if (!event.locals.session) throw redirect(303, '/login');
|
||||||
|
const famId = event.locals.session.famId;
|
||||||
|
try {
|
||||||
|
const preview = await hono.admin.eowPreview(event, famId);
|
||||||
|
return { preview };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
claim: async (event) => {
|
claim: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.session) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.session.famId;
|
||||||
@@ -127,10 +219,15 @@ export const actions = {
|
|||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
if (!configId) return { error: 'Config ID required' };
|
if (!configId) return { error: 'Config ID required' };
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.triggerBonusConfig(event, famId, configId, memberId || undefined);
|
const result = await hono.admin.triggerBonusConfig(
|
||||||
|
event,
|
||||||
|
famId,
|
||||||
|
configId,
|
||||||
|
memberId || undefined
|
||||||
|
);
|
||||||
return { records: result.records || [] };
|
return { records: result.records || [] };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
|
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
import { memberApi } from '$lib/client/api';
|
import { memberApi } from '$lib/client/api';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||||
|
import {
|
||||||
|
weekStart as tzWeekStart,
|
||||||
|
addDaysStr,
|
||||||
|
todayInTz,
|
||||||
|
weekdayInTz,
|
||||||
|
wallClockToUtc,
|
||||||
|
resolveTz,
|
||||||
|
periodStart,
|
||||||
|
periodEnd
|
||||||
|
} from '../../../../../timezone.ts';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,9 +32,16 @@
|
|||||||
let famSlug = $derived(page.params.fam);
|
let famSlug = $derived(page.params.fam);
|
||||||
let username = $derived(page.params.username);
|
let username = $derived(page.params.username);
|
||||||
|
|
||||||
|
// ─── Family timezone (resolved) ───
|
||||||
|
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
|
||||||
|
const famTz = $derived(resolveTz(rawFamTz));
|
||||||
|
|
||||||
// ─── Parent View (admin overview) ───
|
// ─── Parent View (admin overview) ───
|
||||||
let summary = $state(data.summary);
|
let summary = $state(data.summary);
|
||||||
let today = $state(new Date().toISOString().slice(0, 10));
|
let today = $derived(todayInTz(famTz));
|
||||||
|
|
||||||
|
let simulateEow = $state(!!data.settings?.simulateEow);
|
||||||
|
let eowPreview = $state<any>(null);
|
||||||
|
|
||||||
const responseTags = [
|
const responseTags = [
|
||||||
'👏 well done',
|
'👏 well done',
|
||||||
@@ -70,7 +87,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function claimableRewards() {
|
function claimableRewards() {
|
||||||
return parentRewards.filter((r: any) => r.status === 'unclaimed' || r.status === 'requested');
|
return parentRewards.filter(
|
||||||
|
(r: any) =>
|
||||||
|
r.rewardType !== 'points' && (r.status === 'unclaimed' || r.status === 'requested')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function memberClaimable(memberId: string) {
|
function memberClaimable(memberId: string) {
|
||||||
@@ -78,17 +98,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function memberRequested(memberId: string) {
|
function memberRequested(memberId: string) {
|
||||||
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'requested');
|
return parentRewards.filter(
|
||||||
|
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'requested'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function memberOutstanding(memberId: string) {
|
function memberOutstanding(memberId: string) {
|
||||||
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'unclaimed');
|
return parentRewards.filter(
|
||||||
}
|
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'unclaimed'
|
||||||
|
);
|
||||||
function memberRequestedTotal(memberId: string) {
|
|
||||||
return memberRequested(memberId)
|
|
||||||
.filter((r: any) => r.rewardType === 'cash')
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _confetti: any;
|
let _confetti: any;
|
||||||
@@ -174,20 +191,41 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
let claimError = $state('');
|
let claimError = $state('');
|
||||||
let todayChild = $state(new Date().toISOString().slice(0, 10));
|
let todayChild = $derived(todayInTz(famTz));
|
||||||
let togglingIds = $state<string>('');
|
let togglingIds = $state<string>('');
|
||||||
|
|
||||||
function mondayOf(d: Date): string {
|
// ─── Payday countdown (child view) ───
|
||||||
const day = d.getDay();
|
let nowMs = $state(Date.now());
|
||||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
let eowFired = $state(false);
|
||||||
d.setDate(diff);
|
$effect(() => {
|
||||||
return d.toISOString().slice(0, 10);
|
const id = setInterval(() => (nowMs = Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
const paydayDay = $derived(data.payday != null ? Number(data.payday) : 1);
|
||||||
|
const paydayTime = $derived(data.paydayTime || '18:00');
|
||||||
|
const isPaydayToday = $derived(weekdayInTz(new Date(), famTz) === paydayDay);
|
||||||
|
const paydayTarget = $derived.by(() =>
|
||||||
|
new Date(wallClockToUtc(todayInTz(famTz), paydayTime, famTz)).getTime()
|
||||||
|
);
|
||||||
|
const secondsLeft = $derived(Math.max(0, Math.floor((paydayTarget - nowMs) / 1000)));
|
||||||
|
const countdownHours = $derived(Math.floor(secondsLeft / 3600));
|
||||||
|
const countdownMinutes = $derived(Math.floor((secondsLeft % 3600) / 60));
|
||||||
|
const countdownSecs = $derived(secondsLeft % 60);
|
||||||
|
const showCountdown = $derived(isPaydayToday && secondsLeft > 0);
|
||||||
|
$effect(() => {
|
||||||
|
if (role !== 'child' || !isPaydayToday) return;
|
||||||
|
if (secondsLeft > 0 || eowFired) return;
|
||||||
|
if (!deviceToken || !famId) return;
|
||||||
|
eowFired = true;
|
||||||
|
memberApi.payday(deviceToken, famId).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function paydayWeekStart(): string {
|
||||||
|
return tzWeekStart(paydayDay, famTz);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDays(dateStr: string, days: number): string {
|
function addDays(dateStr: string, days: number): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
return addDaysStr(dateStr, days);
|
||||||
d.setDate(d.getDate() + days);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthStartStr(): string {
|
function monthStartStr(): string {
|
||||||
@@ -195,8 +233,8 @@
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayIso = todayInTz(famTz);
|
||||||
const currentWeek = mondayOf(new Date());
|
const currentWeek = paydayWeekStart();
|
||||||
let weekStart = $state(currentWeek);
|
let weekStart = $state(currentWeek);
|
||||||
let weekEnd = $derived(addDays(weekStart, 6));
|
let weekEnd = $derived(addDays(weekStart, 6));
|
||||||
|
|
||||||
@@ -205,7 +243,7 @@
|
|||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round(
|
Math.round(
|
||||||
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
|
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
|
||||||
86400000
|
86400000
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -216,18 +254,46 @@
|
|||||||
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
|
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
|
||||||
return assigned.filter((a) => {
|
return assigned.filter((a) => {
|
||||||
if (a.memberId !== memberId) return false;
|
if (a.memberId !== memberId) return false;
|
||||||
|
if (a.isTodo) return false;
|
||||||
if (!a.seasonIds || a.seasonIds.length === 0) return true;
|
if (!a.seasonIds || a.seasonIds.length === 0) return true;
|
||||||
if (activeSeasonIds.size === 0) return true;
|
if (activeSeasonIds.size === 0) return true;
|
||||||
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
|
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Todos — one-off items with deadlines (sorted: soonest first)
|
||||||
|
let memberTodos = $derived.by(() =>
|
||||||
|
assigned
|
||||||
|
.filter((a) => a.memberId === memberId && a.isTodo)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = a.completeBy || '9999-99-99';
|
||||||
|
const db = b.completeBy || '9999-99-99';
|
||||||
|
if (da !== db) return da.localeCompare(db);
|
||||||
|
return (a.startDate || '').localeCompare(b.startDate || '');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
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, todayChild))
|
||||||
);
|
);
|
||||||
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, todayChild))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Traffic light: days until todo deadline (only non-emoji todos)
|
||||||
|
function todoUrgency(todo: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
|
||||||
|
if (!todo.completeBy) return 'blue';
|
||||||
|
const days = Math.round(
|
||||||
|
(new Date(todo.completeBy + 'T00:00:00').getTime() -
|
||||||
|
new Date(todayChild + 'T00:00:00').getTime()) /
|
||||||
|
86400000
|
||||||
|
);
|
||||||
|
if (days <= 0) return 'black';
|
||||||
|
if (days === 1) return 'red';
|
||||||
|
if (days === 2) return 'orange';
|
||||||
|
if (days === 3) return 'green';
|
||||||
|
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) === todayChild)
|
||||||
);
|
);
|
||||||
@@ -235,7 +301,8 @@
|
|||||||
let weeklyTotal = $derived.by(() => {
|
let weeklyTotal = $derived.by(() => {
|
||||||
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
|
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
|
||||||
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
|
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
|
||||||
return dailyChores.length * 7 + weeklyChores.length;
|
const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length;
|
||||||
|
return dailyChores.length * 7 + weeklyChores.length + activeTodos;
|
||||||
});
|
});
|
||||||
|
|
||||||
let weekCompletions = $derived(
|
let weekCompletions = $derived(
|
||||||
@@ -253,46 +320,56 @@
|
|||||||
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
||||||
);
|
);
|
||||||
|
|
||||||
let allTimeCash = $derived.by(() => {
|
let allTimeCash = $derived.by(() =>
|
||||||
const myCompletions = completions.filter((c) => c.memberId === memberId);
|
rewards
|
||||||
const myRewards = rewards.filter((r) => r.memberId === memberId);
|
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed')
|
||||||
return (
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
myCompletions.reduce((sum, c) => {
|
);
|
||||||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
|
||||||
}, 0) +
|
|
||||||
myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let allTimePoints = $derived.by(() => {
|
let allTimePoints = $derived.by(() =>
|
||||||
const myCompletions = completions.filter((c) => c.memberId === memberId);
|
rewards
|
||||||
const myRewards = rewards.filter((r) => r.memberId === memberId);
|
.filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed')
|
||||||
return (
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
myCompletions.reduce((sum, c) => {
|
);
|
||||||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
// Week-to-date totals from chore completions (not rewards — rewards are created at EOW)
|
||||||
}, 0) +
|
let weekChoreCash = $derived.by(() =>
|
||||||
myRewards
|
weekCompletions.reduce((sum, c) => {
|
||||||
.filter((r) => r.rewardType === 'points')
|
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||||||
);
|
}, 0)
|
||||||
});
|
);
|
||||||
|
let weekChorePoints = $derived.by(() =>
|
||||||
|
weekCompletions.reduce((sum, c) => {
|
||||||
|
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0)
|
||||||
|
);
|
||||||
|
|
||||||
let pendingCash = $derived.by(() =>
|
let pendingCash = $derived.by(() =>
|
||||||
weekRewards
|
weekRewards
|
||||||
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
|
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
|
||||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
);
|
);
|
||||||
let pendingPoints = $derived.by(() =>
|
// Money owed to this member across all time (incl. carry-over from past weeks).
|
||||||
|
let owedCash = $derived.by(() =>
|
||||||
|
rewards
|
||||||
|
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status !== 'claimed')
|
||||||
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
|
);
|
||||||
|
let weekPointsEarned = $derived.by(() =>
|
||||||
weekRewards
|
weekRewards
|
||||||
.filter((r) => r.rewardType === 'points' && r.status !== 'claimed')
|
.filter((r) => r.rewardType === 'points')
|
||||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
);
|
);
|
||||||
const pendingRewards = $derived(
|
const pendingRewards = $derived(
|
||||||
weekRewards.filter((r) => r.status === 'unclaimed' || r.status === 'requested')
|
rewards.filter(
|
||||||
|
(r) =>
|
||||||
|
r.memberId === memberId &&
|
||||||
|
r.rewardType !== 'points' &&
|
||||||
|
(r.status === 'unclaimed' || r.status === 'requested')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let weekBonusTallies = $derived.by(() => {
|
let weekBonusTallies = $derived.by(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
for (const r of weekRewards) {
|
for (const r of weekRewards) {
|
||||||
@@ -303,6 +380,55 @@
|
|||||||
return [...map.entries()].map(([name, count]) => ({ name, count }));
|
return [...map.entries()].map(([name, count]) => ({ name, count }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Threshold Goals (bonus configs the member is chasing) ───
|
||||||
|
let thresholdGoals = $derived.by(() => {
|
||||||
|
return bonusConfigs
|
||||||
|
.filter((cfg) => {
|
||||||
|
if (cfg.status !== 'active') return false;
|
||||||
|
if (cfg.type === 'manual') return false;
|
||||||
|
if (cfg.target !== 'individual') return false;
|
||||||
|
return !cfg.memberId || cfg.memberId === memberId;
|
||||||
|
})
|
||||||
|
.map((cfg) => {
|
||||||
|
const per = cfg.period || 'weekly';
|
||||||
|
const pStart = periodStart(per, paydayDay, famTz);
|
||||||
|
const pEnd = periodEnd(per, pStart);
|
||||||
|
|
||||||
|
const periodCompletions = completions.filter(
|
||||||
|
(c) =>
|
||||||
|
c.memberId === memberId &&
|
||||||
|
(c.date?.slice(0, 10) || c.date) >= pStart &&
|
||||||
|
(c.date?.slice(0, 10) || c.date) <= pEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
if (cfg.type === 'threshold') {
|
||||||
|
current = periodCompletions.reduce((sum, c) => {
|
||||||
|
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
} else if (cfg.type === 'count') {
|
||||||
|
current = periodCompletions.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReward = rewards.find(
|
||||||
|
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
|
||||||
|
);
|
||||||
|
const criteria = Number(cfg.criteriaValue) || 0;
|
||||||
|
const achieved = existingReward ? true : criteria > 0 && current >= criteria;
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: cfg,
|
||||||
|
current,
|
||||||
|
criteriaValue: criteria,
|
||||||
|
achieved,
|
||||||
|
rewardStatus: existingReward?.status || null,
|
||||||
|
periodStart: pStart,
|
||||||
|
periodEnd: pEnd
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function shortDate(dateStr: string): string {
|
function shortDate(dateStr: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
@@ -598,12 +724,38 @@
|
|||||||
{#each parentMembers as m}
|
{#each parentMembers as m}
|
||||||
{@const requested = memberRequested(m.id)}
|
{@const requested = memberRequested(m.id)}
|
||||||
{@const outstanding = memberOutstanding(m.id)}
|
{@const outstanding = memberOutstanding(m.id)}
|
||||||
{@const total = memberRequestedTotal(m.id)}
|
{@const claimable = memberClaimable(m.id)}
|
||||||
{#if requested.length > 0 || outstanding.length > 0}
|
{@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')}
|
||||||
|
{@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')}
|
||||||
|
{@const requestedTotal = cashRequested.reduce(
|
||||||
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
{@const total = cashClaimable.reduce((sum: number, r: any) => sum + Number(r.value), 0)}
|
||||||
|
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
||||||
|
{#if hasClaims}
|
||||||
<div class="member-claims">
|
<div class="member-claims">
|
||||||
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
||||||
{#if total > 0}
|
{#if total > 0}
|
||||||
<p class="member-total">£{total.toFixed(2)} requested</p>
|
<p class="member-total">£{total.toFixed(2)} owed</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Issue All: only when payday has run (rewards are requested) -->
|
||||||
|
{#if cashRequested.length > 0}
|
||||||
|
<form
|
||||||
|
class="issue-all-form"
|
||||||
|
method="POST"
|
||||||
|
action="?/issueAll"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async (args: any) => handleResult(args);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="memberId" value={m.id} />
|
||||||
|
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||||
|
<Button type="submit" size="sm" variant="secondary"
|
||||||
|
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if requested.length > 0}
|
{#if requested.length > 0}
|
||||||
@@ -627,21 +779,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/each}
|
{/each}
|
||||||
{#if requested.length > 1}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/issueAll"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async (args: any) => handleResult(args);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="memberId" value={m.id} />
|
|
||||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
|
||||||
<Button type="submit" size="sm" variant="secondary"
|
|
||||||
>Issue All ({requested.length})</Button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if outstanding.length > 0}
|
{#if outstanding.length > 0}
|
||||||
@@ -684,6 +821,82 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
<CardGrid>
|
||||||
|
<Card cols={3} title="Debug: Simulate Payday" accent="#f59e0b">
|
||||||
|
<p class="eow-desc">
|
||||||
|
Read-only preview of what the payday rollover will produce. Nothing here is written.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/setEow"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
const d = (result as any).data || {};
|
||||||
|
if (d.error) toast = d.error;
|
||||||
|
else simulateEow = !!d.simulateEow;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="on" value={simulateEow ? 'false' : 'true'} />
|
||||||
|
<button type="submit" class="eow-switch" class:on={simulateEow}>
|
||||||
|
{simulateEow ? 'Simulation ON' : 'Simulation OFF'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="eow-note">
|
||||||
|
This toggles the family debug flag only. It does not alter any live data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/previewEow"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
const d = (result as any).data || {};
|
||||||
|
if (d.error) toast = d.error;
|
||||||
|
else eowPreview = d.preview;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" size="sm" variant="secondary">Preview rollover</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if eowPreview}
|
||||||
|
<div class="eow-out">
|
||||||
|
<p class="eow-range">Week {eowPreview.weekStart} → {eowPreview.weekEnd}</p>
|
||||||
|
<p class="eow-reset">
|
||||||
|
On rollover these {eowPreview.completionsThisWeek} completions reset; week starts anew at
|
||||||
|
{eowPreview.nextWeekStart}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="eow-summaries">
|
||||||
|
{#each eowPreview.summaries as s}
|
||||||
|
<div class="eow-row">
|
||||||
|
<span class="eow-name">{s.memberName}</span>
|
||||||
|
<span class="eow-pts">{s.pointsEarned} pts</span>
|
||||||
|
<span class="eow-money">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
|
||||||
|
<span class="eow-chores">{s.choresCompleted} chores</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if eowPreview.predictedRewards.length}
|
||||||
|
<p class="eow-sec">Rewards that would be auto-created</p>
|
||||||
|
<div class="eow-rewards">
|
||||||
|
{#each eowPreview.predictedRewards as r}
|
||||||
|
<span class="eow-reward"
|
||||||
|
>{r.memberName} · {r.config} · {r.type === 'cash'
|
||||||
|
? '£' + Number(r.value).toFixed(2)
|
||||||
|
: r.value + ' pts'}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="eow-none">No new rewards would be auto-created this week.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Child View -->
|
<!-- Child View -->
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -691,6 +904,27 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error">{error}</p>
|
<p class="error">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if owedCash > 0}
|
||||||
|
<div class="payday-banner">
|
||||||
|
🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent!
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if showCountdown}
|
||||||
|
<div class="payday-countdown">
|
||||||
|
<div class="pd-icon">💰</div>
|
||||||
|
<div class="pd-body">
|
||||||
|
<p class="pd-title">Payday is today!</p>
|
||||||
|
<p class="pd-num">
|
||||||
|
{#if countdownHours > 0}
|
||||||
|
{countdownHours}h {countdownMinutes}m left
|
||||||
|
{:else}
|
||||||
|
{countdownMinutes}m {countdownSecs}s left
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="pd-sub">Your earnings get settled at {paydayTime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-top">
|
<div class="hero-top">
|
||||||
@@ -822,7 +1056,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="hero-null">no bonuses earned yet</span>
|
<span class="hero-null">no bonuses earned yet</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="hero-days"><b>{daysLeft}</b> days left</div>
|
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="hero-nav">
|
<nav class="hero-nav">
|
||||||
@@ -836,18 +1070,55 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- PENDING 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">£{pendingCash.toFixed(2)}</span>
|
<span class="tile-val">£{weekChoreCash.toFixed(2)}</span>
|
||||||
<span class="tile-lbl">pending cash</span>
|
<span class="tile-lbl">cash this week</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile tile-pts">
|
<div class="tile tile-pts">
|
||||||
<span class="tile-val">{pendingPoints}</span>
|
<span class="tile-val">{weekChorePoints}</span>
|
||||||
<span class="tile-lbl">pending points</span>
|
<span class="tile-lbl">points this week</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GOALS (threshold bonus configs) -->
|
||||||
|
{#if thresholdGoals.length > 0}
|
||||||
|
<div class="goals">
|
||||||
|
{#each thresholdGoals as goal}
|
||||||
|
{@const pct =
|
||||||
|
goal.criteriaValue > 0
|
||||||
|
? Math.min(100, Math.round((goal.current / goal.criteriaValue) * 100))
|
||||||
|
: 0}
|
||||||
|
<div class="goal-card" class:goal-achieved={goal.achieved}>
|
||||||
|
<div class="goal-head">
|
||||||
|
<span class="goal-name">{goal.config.name}</span>
|
||||||
|
<span class="goal-reward">
|
||||||
|
{#if goal.config.rewardType === 'cash'}
|
||||||
|
£{Number(goal.config.rewardValue).toFixed(2)}
|
||||||
|
{:else if goal.config.rewardType === 'prize'}
|
||||||
|
🎁
|
||||||
|
{:else}
|
||||||
|
{goal.config.rewardValue} pts
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="goal-bar-wrap">
|
||||||
|
<div class="goal-bar-fill" style="width:{pct}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="goal-foot">
|
||||||
|
<span class="goal-progress">{goal.current} / {goal.criteriaValue}</span>
|
||||||
|
{#if goal.achieved}
|
||||||
|
<span class="goal-badge">✅ earned</span>
|
||||||
|
{:else}
|
||||||
|
<span class="goal-pct">{pct}%</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- KANBAN -->
|
<!-- KANBAN -->
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
<div class="column col-daily">
|
<div class="column col-daily">
|
||||||
@@ -865,8 +1136,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="column col-weekly">
|
<div class="column col-weekly">
|
||||||
<h2>📅 Weekly ({weeklyPending.length})</h2>
|
<h2>
|
||||||
{#if weeklyPending.length === 0}
|
📅 Weekly ({weeklyPending.length +
|
||||||
|
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
|
||||||
|
</h2>
|
||||||
|
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
|
||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each weeklyPending as chore}
|
{#each weeklyPending as chore}
|
||||||
@@ -876,6 +1150,38 @@
|
|||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
|
||||||
|
{@const urgency = todoUrgency(todo)}
|
||||||
|
{#if todo.type === 'emoji'}
|
||||||
|
<div
|
||||||
|
class="chore todo-chore todo-display"
|
||||||
|
class:todo-blue={urgency === 'blue'}
|
||||||
|
class:todo-green={urgency === 'green'}
|
||||||
|
class:todo-orange={urgency === 'orange'}
|
||||||
|
class:todo-red={urgency === 'red'}
|
||||||
|
class:todo-black={urgency === 'black'}
|
||||||
|
>
|
||||||
|
<span class="checkbox">📋</span>
|
||||||
|
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||||||
|
<span class="chore-value">🎯</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="chore todo-chore"
|
||||||
|
class:todo-blue={urgency === 'blue'}
|
||||||
|
class:todo-green={urgency === 'green'}
|
||||||
|
class:todo-orange={urgency === 'orange'}
|
||||||
|
class:todo-red={urgency === 'red'}
|
||||||
|
class:todo-black={urgency === 'black'}
|
||||||
|
onclick={() => toggle(todo)}
|
||||||
|
disabled={!!togglingIds}
|
||||||
|
>
|
||||||
|
<span class="checkbox">📋</span>
|
||||||
|
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||||||
|
<span class="chore-value">{todo.value} {todo.type}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="column col-done">
|
<div class="column col-done">
|
||||||
@@ -926,7 +1232,9 @@
|
|||||||
{r.value} pts
|
{r.value} pts
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if isReq}
|
{#if r.rewardType === 'points'}
|
||||||
|
<span class="wr-pending">✓ credited</span>
|
||||||
|
{:else if isReq}
|
||||||
<span class="wr-pending">⏳ waiting</span>
|
<span class="wr-pending">⏳ waiting</span>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -1439,6 +1747,82 @@
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Goals (threshold bonus progress) ── */
|
||||||
|
.goals {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.goal-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-left: 4px solid #6366f1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.goal-achieved {
|
||||||
|
border-left-color: #10b981;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
.goal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.goal-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.goal-reward {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #6366f1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.goal-achieved .goal-reward {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.goal-bar-wrap {
|
||||||
|
height: 8px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.goal-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.goal-achieved .goal-bar-fill {
|
||||||
|
background: linear-gradient(90deg, #10b981, #34d399);
|
||||||
|
}
|
||||||
|
.goal-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.goal-progress {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.goal-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.goal-pct {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Kanban ── */
|
/* ── Kanban ── */
|
||||||
.kanban {
|
.kanban {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1489,6 +1873,37 @@
|
|||||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
||||||
border-color: #c7d2fe;
|
border-color: #c7d2fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Todo traffic light borders ── */
|
||||||
|
.chore.todo-chore {
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
}
|
||||||
|
.chore.todo-blue {
|
||||||
|
border-left-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.chore.todo-green {
|
||||||
|
border-left-color: #22c55e;
|
||||||
|
}
|
||||||
|
.chore.todo-orange {
|
||||||
|
border-left-color: #f97316;
|
||||||
|
}
|
||||||
|
.chore.todo-red {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
.chore.todo-black {
|
||||||
|
border-left-color: #1f2937;
|
||||||
|
}
|
||||||
|
.todo-display {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.todo-display:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-top-color: #e5e7eb;
|
||||||
|
border-right-color: #e5e7eb;
|
||||||
|
border-bottom-color: #e5e7eb;
|
||||||
|
}
|
||||||
.chore:disabled {
|
.chore:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -1602,4 +2017,148 @@
|
|||||||
.wr-cta:hover {
|
.wr-cta:hover {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eow-desc {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payday-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.85rem 1.1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||||
|
color: #451a03;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
|
||||||
|
}
|
||||||
|
.payday-banner b {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payday-countdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
|
||||||
|
}
|
||||||
|
.pd-icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.pd-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.pd-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.pd-num {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pd-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.eow-switch {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.eow-switch.on {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.eow-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0.4rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
.eow-out {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-top: 1px dashed #e5e7eb;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
.eow-range,
|
||||||
|
.eow-reset {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.eow-reset {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.eow-summaries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
.eow-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid #eeeff2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafbfc;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.eow-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.eow-pts {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.eow-money {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.eow-chores {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.eow-sec {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
.eow-rewards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.eow-reward {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.eow-none {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -258,18 +258,6 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
claimReward: async (event) => {
|
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
|
||||||
const rewardId = fd.get('id') as string;
|
|
||||||
try {
|
|
||||||
await hono.admin.claimReward(event, famId, rewardId);
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
evaluate: async (event) => {
|
evaluate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.session) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.session.famId;
|
||||||
|
|||||||
@@ -197,38 +197,12 @@
|
|||||||
showEditModal = true;
|
showEditModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function claimReward(rewardId: string) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('id', rewardId);
|
|
||||||
try {
|
|
||||||
await fetch('?/claimReward', { method: 'POST', body: fd });
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showPeriodForCreate = $derived(createVals.occurrence !== 'once');
|
const showPeriodForCreate = $derived(createVals.occurrence !== 'once');
|
||||||
const showCriteriaForCreate = $derived(createVals.type !== 'manual');
|
const showCriteriaForCreate = $derived(createVals.type !== 'manual');
|
||||||
|
|
||||||
let outstandingConfigs = $derived(allBonusConfigs.filter((c: BonusConfig) => isOutstanding(c)));
|
|
||||||
let doneConfigs = $derived(
|
let doneConfigs = $derived(
|
||||||
configs.filter((c: BonusConfig) => c.status === 'completed') as BonusConfig[]
|
configs.filter((c: BonusConfig) => c.status === 'completed') as BonusConfig[]
|
||||||
);
|
);
|
||||||
|
|
||||||
function isCompleted(cfg: BonusConfig): boolean {
|
|
||||||
const prog = progressByConfigId.get(cfg.id);
|
|
||||||
if (cfg.occurrence === 'once') {
|
|
||||||
const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id);
|
|
||||||
return rewards.length > 0;
|
|
||||||
}
|
|
||||||
if (!prog) return false;
|
|
||||||
return prog.progress.every((p) => p.achieved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo - redundant?
|
|
||||||
function isOutstanding(cfg: BonusConfig): boolean {
|
|
||||||
if (!isCompleted(cfg)) return false;
|
|
||||||
const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id);
|
|
||||||
return rewards.some((r) => r.status === 'unclaimed');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Bonuses" />
|
<ViewHeader title="Bonuses" />
|
||||||
@@ -297,12 +271,7 @@
|
|||||||
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}>
|
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}>
|
||||||
<h3>Member</h3>
|
<h3>Member</h3>
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
|
||||||
<div
|
<div class="col-card" class:disabled={disabledIds.has(cfg.id)}>
|
||||||
class="col-card"
|
|
||||||
class:done={outstandingConfigs.includes(cfg)}
|
|
||||||
class:outstanding={outstandingConfigs.includes(cfg)}
|
|
||||||
class:disabled={disabledIds.has(cfg.id)}
|
|
||||||
>
|
|
||||||
<div class="col-card-head">
|
<div class="col-card-head">
|
||||||
<strong>{cfg.name}</strong>
|
<strong>{cfg.name}</strong>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
@@ -333,18 +302,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="stat {p?.state ?? 'pending'}">
|
<span class="stat {p?.state ?? 'pending'}">
|
||||||
{p ? (outstandingConfigs.includes(cfg) ? 'Outstanding' : p.state) : 'Pending'}
|
{p ? p.state : 'Pending'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<div class="col-card-foot">
|
<div class="col-card-foot">
|
||||||
{#if !outstandingConfigs.includes(cfg)}
|
<Button variant="secondary" size="sm" onclick={() => openEditConfig(cfg)}>
|
||||||
<Button variant="secondary" size="sm" onclick={() => openEditConfig(cfg)}>
|
Edit
|
||||||
Edit
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -358,12 +325,7 @@
|
|||||||
<h3>Competition</h3>
|
<h3>Competition</h3>
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
|
||||||
{@const prog = progressByConfigId.get(cfg.id)}
|
{@const prog = progressByConfigId.get(cfg.id)}
|
||||||
<div
|
<div class="col-card" class:disabled={disabledIds.has(cfg.id)}>
|
||||||
class="col-card"
|
|
||||||
class:done={outstandingConfigs.includes(cfg)}
|
|
||||||
class:outstanding={outstandingConfigs.includes(cfg)}
|
|
||||||
class:disabled={disabledIds.has(cfg.id)}
|
|
||||||
>
|
|
||||||
<div class="col-card-head">
|
<div class="col-card-head">
|
||||||
<strong>{cfg.name}</strong>
|
<strong>{cfg.name}</strong>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
@@ -391,18 +353,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="stat {p.state}">
|
<span class="stat {p.state}">
|
||||||
{outstandingConfigs.includes(cfg) ? 'Outstanding' : p.state}
|
{p.state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="col-card-foot">
|
<div class="col-card-foot">
|
||||||
{#if !outstandingConfigs.includes(cfg)}
|
<Button variant="secondary" size="sm" onclick={() => openEditConfig(cfg)}>
|
||||||
<Button variant="secondary" size="sm" onclick={() => openEditConfig(cfg)}>
|
Edit
|
||||||
Edit
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -416,12 +376,7 @@
|
|||||||
<h3>Collaboration</h3>
|
<h3>Collaboration</h3>
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
|
||||||
{@const prog = progressByConfigId.get(cfg.id)}
|
{@const prog = progressByConfigId.get(cfg.id)}
|
||||||
<div
|
<div class="col-card" class:disabled={disabledIds.has(cfg.id)}>
|
||||||
class="col-card"
|
|
||||||
class:done={outstandingConfigs.includes(cfg)}
|
|
||||||
class:outstanding={outstandingConfigs.includes(cfg)}
|
|
||||||
class:disabled={disabledIds.has(cfg.id)}
|
|
||||||
>
|
|
||||||
<div class="col-card-head">
|
<div class="col-card-head">
|
||||||
<strong>{cfg.name}</strong>
|
<strong>{cfg.name}</strong>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
@@ -452,17 +407,15 @@
|
|||||||
{cfg.type === 'threshold' ? 'pts' : 'chores'}</span
|
{cfg.type === 'threshold' ? 'pts' : 'chores'}</span
|
||||||
>
|
>
|
||||||
<span class="stat {team.state}">
|
<span class="stat {team.state}">
|
||||||
{outstandingConfigs.includes(cfg) ? 'Outstanding' : team.state}
|
{team.state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="col-card-foot">
|
<div class="col-card-foot">
|
||||||
{#if !outstandingConfigs.includes(cfg)}
|
<Button variant="secondary" size="sm" onclick={() => openEditConfig(cfg)}>
|
||||||
<Button variant="secondary" size="sm" onclick={() => openEditConfig(cfg)}>
|
Edit
|
||||||
Edit
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -472,33 +425,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if outstandingConfigs.length > 0}
|
|
||||||
<div class="outstanding-section">
|
|
||||||
<h3>Outstanding — Issue to complete</h3>
|
|
||||||
<div class="outstanding-grid">
|
|
||||||
{#each outstandingConfigs as cfg}
|
|
||||||
{@const rewards = allRewards.filter(
|
|
||||||
(r) => r.bonusConfigId === cfg.id && r.status === 'unclaimed'
|
|
||||||
)}
|
|
||||||
<div class="outstanding-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<strong>{cfg.name}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="reward-preview">
|
|
||||||
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
|
||||||
</div>
|
|
||||||
{#each rewards as r}
|
|
||||||
<div class="claim-row">
|
|
||||||
<span>{memberName(r.memberId)}</span>
|
|
||||||
<button onclick={() => claimReward(r.id)} class="claim-btn">Claim</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if doneConfigs.length > 0}
|
{#if doneConfigs.length > 0}
|
||||||
<div class="completed-section">
|
<div class="completed-section">
|
||||||
<h3>Completed</h3>
|
<h3>Completed</h3>
|
||||||
@@ -919,10 +845,6 @@
|
|||||||
.col-card.done {
|
.col-card.done {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
.col-card.outstanding {
|
|
||||||
border-color: #f59e0b;
|
|
||||||
background: #fffbeb;
|
|
||||||
}
|
|
||||||
.col-card.disabled {
|
.col-card.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
filter: grayscale(0.5);
|
filter: grayscale(0.5);
|
||||||
@@ -1003,15 +925,6 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.claim-btn {
|
|
||||||
background: #6366f1;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -1109,33 +1022,6 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.outstanding-section {
|
|
||||||
margin-top: 2rem;
|
|
||||||
border-top: 2px solid #f59e0b;
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
.outstanding-section h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.outstanding-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.outstanding-card {
|
|
||||||
background: #fffbeb;
|
|
||||||
border: 2px solid #f59e0b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
.claim-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.2rem 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.completed-section {
|
.completed-section {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 2px solid #10b981;
|
border-top: 2px solid #10b981;
|
||||||
|
|||||||
@@ -86,18 +86,33 @@ export const actions = {
|
|||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.session.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
|
const isTodo = fd.get('isTodo') === '1';
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
const frequency = fd.get('frequency');
|
|
||||||
const type = fd.get('type');
|
if (isTodo) {
|
||||||
const value = fd.get('value');
|
const type = fd.get('type') as string;
|
||||||
const customName = fd.get('customName');
|
const customName = fd.get('customName') as string;
|
||||||
const seasonIdsRaw = fd.get('seasonIds') as string;
|
const completeBy = fd.get('completeBy') as string;
|
||||||
if (frequency) data.frequency = frequency;
|
const startDate = fd.get('startDate') as string;
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
if (value) data.value = parseFloat(value as string) || 0;
|
if (type === 'emoji') data.value = 0;
|
||||||
data.customName = (customName as string) || undefined;
|
else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
||||||
if (seasonIdsRaw || seasonIdsRaw === '') {
|
data.customName = customName || undefined;
|
||||||
data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : [];
|
if (completeBy) data.completeBy = completeBy;
|
||||||
|
if (startDate) data.startDate = startDate;
|
||||||
|
} else {
|
||||||
|
const frequency = fd.get('frequency');
|
||||||
|
const type = fd.get('type');
|
||||||
|
const value = fd.get('value');
|
||||||
|
const customName = fd.get('customName');
|
||||||
|
const seasonIdsRaw = fd.get('seasonIds') as string;
|
||||||
|
if (frequency) data.frequency = frequency;
|
||||||
|
if (type) data.type = type;
|
||||||
|
if (value) data.value = parseFloat(value as string) || 0;
|
||||||
|
data.customName = (customName as string) || undefined;
|
||||||
|
if (seasonIdsRaw || seasonIdsRaw === '') {
|
||||||
|
data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
|
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
|
||||||
@@ -107,4 +122,59 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createTodo: async (event) => {
|
||||||
|
if (!event.locals.session) throw redirect(303, '/login');
|
||||||
|
const famId = event.locals.session.famId;
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const memberId = fd.get('memberId') as string;
|
||||||
|
const 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 todoStart = fd.get('todoStart') as string;
|
||||||
|
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
||||||
|
const customDate = fd.get('customDate') as string;
|
||||||
|
|
||||||
|
if (!memberId || !name || !type) {
|
||||||
|
return fail(400, { error: 'Member, name, and type are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute startDate
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
let startDate = today;
|
||||||
|
if (todoStart === 'next-week') {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + (7 - d.getDay() + 1)); // next Monday
|
||||||
|
startDate = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute completeBy
|
||||||
|
let completeBy = '';
|
||||||
|
if (todoCompleteBy === 'custom' && customDate) {
|
||||||
|
completeBy = customDate;
|
||||||
|
} else {
|
||||||
|
// Next week Saturday
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + (7 - d.getDay() + 6));
|
||||||
|
completeBy = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
memberId,
|
||||||
|
frequency: 'weekly',
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
customName: name,
|
||||||
|
isTodo: true,
|
||||||
|
startDate,
|
||||||
|
completeBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
|
||||||
|
return { record };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,13 @@ export const actions = {
|
|||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const payday = parseInt(fd.get('payday') as string, 10);
|
const payday = parseInt(fd.get('payday') as string, 10);
|
||||||
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
||||||
return await hono.admin.updatePayday(event, famId, payday);
|
const paydayTime = fd.get('paydayTime') as string;
|
||||||
|
if (paydayTime && !/^\d{2}:\d{2}$/.test(paydayTime)) return { error: 'Payday time must be HH:MM' };
|
||||||
|
const timezone = fd.get('timezone') as string;
|
||||||
|
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
|
||||||
|
return { error: 'Timezone must be an IANA name or auto' };
|
||||||
|
}
|
||||||
|
return await hono.admin.updatePayday(event, famId, payday, paydayTime, timezone || undefined);
|
||||||
},
|
},
|
||||||
|
|
||||||
createSeason: async (event: RequestEvent) => {
|
createSeason: async (event: RequestEvent) => {
|
||||||
|
|||||||
@@ -1,47 +1,108 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { pb, initPbFromCookie } from '$lib/pocketbase';
|
||||||
import { onDestroy } from 'svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
|
import { COMMON_TIMEZONES } from '../../../../../../timezone.ts';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let fam = $state(data.fam)
|
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the
|
||||||
let famSlug = $state(page.params.fam)
|
// public famStore stream. This admin-only page subscribes to `fams` with the
|
||||||
|
// authenticated PB instance (pb_token cookie), page-local only.
|
||||||
|
let fam = $state(data.fam);
|
||||||
|
let famSlug = $state(page.params.fam);
|
||||||
|
|
||||||
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`)
|
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`);
|
||||||
let addName = $state('')
|
let addName = $state('');
|
||||||
let rename = $state('')
|
let rename = $state('');
|
||||||
let selectedMember = $state('')
|
let selectedMember = $state('');
|
||||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1)
|
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||||||
let showQR = $state(false)
|
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||||||
let qrDataUrl = $state('')
|
let paydayTimes = $state([
|
||||||
let copied = $state(false)
|
'06:00',
|
||||||
let parentInviteEmail = $state('')
|
'07:00',
|
||||||
|
'08:00',
|
||||||
|
'09:00',
|
||||||
|
'10:00',
|
||||||
|
'11:00',
|
||||||
|
'12:00',
|
||||||
|
'13:00',
|
||||||
|
'14:00',
|
||||||
|
'15:00',
|
||||||
|
'16:00',
|
||||||
|
'17:00',
|
||||||
|
'18:00',
|
||||||
|
'19:00',
|
||||||
|
'20:00',
|
||||||
|
'21:00',
|
||||||
|
'22:00'
|
||||||
|
]);
|
||||||
|
let timezone = $state(fam?.timezone || 'auto');
|
||||||
|
let timezoneOptions = $state(COMMON_TIMEZONES);
|
||||||
|
let detectedTz = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof Intl !== 'undefined') {
|
||||||
|
try {
|
||||||
|
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch {
|
||||||
|
detectedTz = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let showQR = $state(false);
|
||||||
|
let qrDataUrl = $state('');
|
||||||
|
let copied = $state(false);
|
||||||
|
let parentInviteEmail = $state('');
|
||||||
|
|
||||||
let selectedInviteUrl = $derived(
|
let selectedInviteUrl = $derived(
|
||||||
selectedMember
|
selectedMember ? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}` : childInviteUrl
|
||||||
? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}`
|
);
|
||||||
: childInviteUrl
|
|
||||||
)
|
|
||||||
|
|
||||||
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
|
let members = $state(famStore.initialized ? famStore.members : data.members || []);
|
||||||
let deletingSeason = $state<any>(null)
|
let deletingSeason = $state<any>(null);
|
||||||
|
|
||||||
|
function syncFamFromRecord(record: any) {
|
||||||
|
fam = record;
|
||||||
|
payday = Number(record.payday ?? 1);
|
||||||
|
paydayTime = record.paydayTime || '18:00';
|
||||||
|
timezone = record.timezone || 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
initPbFromCookie();
|
||||||
|
if (!fam?.id) return;
|
||||||
|
try {
|
||||||
|
await pb.collection('fams').subscribe(fam.id, ({ action, record }) => {
|
||||||
|
if (action === 'update') syncFamFromRecord(record);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('fams subscribe failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (fam?.id)
|
||||||
|
pb.collection('fams')
|
||||||
|
.unsubscribe(fam.id)
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
function copy(url: string) {
|
function copy(url: string) {
|
||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url);
|
||||||
copied = true
|
copied = true;
|
||||||
setTimeout(() => copied = false, 2000)
|
setTimeout(() => (copied = false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateQR(url: string) {
|
async function generateQR(url: string) {
|
||||||
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 })
|
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleParentInvite() {
|
function handleParentInvite() {
|
||||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail)
|
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,7 +131,12 @@ import { onDestroy } from 'svelte';
|
|||||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -78,19 +144,53 @@ import { onDestroy } from 'svelte';
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Payday" cols={1}>
|
<Card title="Payday" cols={1}>
|
||||||
<form method="POST" action="?/updatePayday" use:enhance>
|
<form
|
||||||
<select name="payday" bind:value={payday}>
|
method="POST"
|
||||||
<option value={0}>Sunday</option>
|
action="?/updatePayday"
|
||||||
<option value={1}>Monday</option>
|
use:enhance={() => {
|
||||||
<option value={2}>Tuesday</option>
|
return async ({ result }) => {
|
||||||
<option value={3}>Wednesday</option>
|
if (result.type === 'error') {
|
||||||
<option value={4}>Thursday</option>
|
alert(result.error || 'Failed to update payday');
|
||||||
<option value={5}>Friday</option>
|
}
|
||||||
<option value={6}>Saturday</option>
|
// One-way: no invalidation — local state is already correct,
|
||||||
</select>
|
// and the PB subscription handles cross-device sync.
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="payday-form"
|
||||||
|
>
|
||||||
|
<div class="payday-row">
|
||||||
|
<label>Day</label>
|
||||||
|
<select name="payday" bind:value={payday}>
|
||||||
|
<option value={0}>Sunday</option>
|
||||||
|
<option value={1}>Monday</option>
|
||||||
|
<option value={2}>Tuesday</option>
|
||||||
|
<option value={3}>Wednesday</option>
|
||||||
|
<option value={4}>Thursday</option>
|
||||||
|
<option value={5}>Friday</option>
|
||||||
|
<option value={6}>Saturday</option>
|
||||||
|
</select>
|
||||||
|
<label>Time</label>
|
||||||
|
<select name="paydayTime" bind:value={paydayTime}>
|
||||||
|
{#each paydayTimes as t}
|
||||||
|
<option value={t}>{t}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="payday-row">
|
||||||
|
<label>Timezone</label>
|
||||||
|
<select name="timezone" bind:value={timezone}>
|
||||||
|
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
|
||||||
|
{#each timezoneOptions as tz}
|
||||||
|
<option value={tz}>{tz}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<Button type="submit" size="sm">Set payday</Button>
|
<Button type="submit" size="sm">Set payday</Button>
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">The week starts on this day for tracking weekly chores and bonus cycles.</p>
|
<p class="hint">
|
||||||
|
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
||||||
|
follows each device.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Invite Children" cols={1}>
|
<Card title="Invite Children" cols={1}>
|
||||||
@@ -114,7 +214,17 @@ import { onDestroy } from 'svelte';
|
|||||||
{copied ? 'Copied!' : 'Copy link'}
|
{copied ? 'Copied!' : 'Copy link'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button onclick={async () => { showQR = !showQR; if (!showQR) { qrDataUrl = ''; } else { await generateQR(selectedInviteUrl); } }} size="sm">
|
<Button
|
||||||
|
onclick={async () => {
|
||||||
|
showQR = !showQR;
|
||||||
|
if (!showQR) {
|
||||||
|
qrDataUrl = '';
|
||||||
|
} else {
|
||||||
|
await generateQR(selectedInviteUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{showQR ? 'Hide QR' : 'Show QR'}
|
{showQR ? 'Hide QR' : 'Show QR'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -131,11 +241,7 @@ import { onDestroy } from 'svelte';
|
|||||||
<Card title="Invite Parent" cols={1}>
|
<Card title="Invite Parent" cols={1}>
|
||||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||||
<div class="invite-row">
|
<div class="invite-row">
|
||||||
<input
|
<input type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
||||||
type="email"
|
|
||||||
bind:value={parentInviteEmail}
|
|
||||||
placeholder="parent@example.com"
|
|
||||||
/>
|
|
||||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">They will set up their own password on first login.</p>
|
<p class="hint">They will set up their own password on first login.</p>
|
||||||
@@ -155,7 +261,7 @@ import { onDestroy } from 'svelte';
|
|||||||
<li>
|
<li>
|
||||||
<span class="dot" style="background:{s.color}"></span>
|
<span class="dot" style="background:{s.color}"></span>
|
||||||
{s.name}
|
{s.name}
|
||||||
<Button variant="danger" size="sm" onclick={() => deletingSeason = s}>Remove</Button>
|
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -163,18 +269,26 @@ import { onDestroy } from 'svelte';
|
|||||||
|
|
||||||
<!-- Delete Season Modal -->
|
<!-- Delete Season Modal -->
|
||||||
{#if deletingSeason}
|
{#if deletingSeason}
|
||||||
<div class="overlay" onclick={() => deletingSeason = null} role="presentation">
|
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||||
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
|
<p class="warning">
|
||||||
<form method="POST" action="?/deleteSeason" use:enhance={() => { return async ({ result }) => {
|
All chores assigned to this season will also be removed. This cannot be undone.
|
||||||
if (result.type === 'success') {
|
</p>
|
||||||
deletingSeason = null;
|
<form
|
||||||
}
|
method="POST"
|
||||||
}}}>
|
action="?/deleteSeason"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
deletingSeason = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" onclick={() => deletingSeason = null}>Cancel</button>
|
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||||||
<button type="submit" class="danger">Delete Season</button>
|
<button type="submit" class="danger">Delete Season</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -193,10 +307,28 @@ import { onDestroy } from 'svelte';
|
|||||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="POST" action="?/completeWeek" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Week completed!'); await update(); }; }}>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/completeWeek"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') alert('Week completed!');
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="?/generateData" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Test data generated!'); await update(); }; }}>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/generateData"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') alert('Test data generated!');
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input type="hidden" name="days" value="7" />
|
<input type="hidden" name="days" value="7" />
|
||||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -206,32 +338,192 @@ import { onDestroy } from 'svelte';
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.section { margin: 0.5rem 0; }
|
.section {
|
||||||
.code { font-family: monospace; font-size: 1.2rem; padding: 0.5rem; background: #f3f4f6; border-radius: 4px; display: inline-block; }
|
margin: 0.5rem 0;
|
||||||
.invite-url { font-family: monospace; font-size: 0.9rem; word-break: break-all; background: #f9fafb; padding: 0.4rem; border-radius: 4px; }
|
}
|
||||||
.invite-row { display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0; }
|
.code {
|
||||||
.invite-row select { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
|
font-family: monospace;
|
||||||
.invite-row input { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
|
font-size: 1.2rem;
|
||||||
.hint { font-size: 0.85rem; color: #9ca3af; }
|
padding: 0.5rem;
|
||||||
.actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; }
|
background: #f3f4f6;
|
||||||
.qr { margin-top: 0.5rem; border: 1px solid #e5e7eb; border-radius: 4px; }
|
border-radius: 4px;
|
||||||
ul { list-style: none; padding: 0; }
|
display: inline-block;
|
||||||
li { padding: 0.3rem 0; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
}
|
||||||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
.invite-url {
|
||||||
.link { font-size: 0.85rem; color: #6366f1; }
|
font-family: monospace;
|
||||||
form { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
font-size: 0.9rem;
|
||||||
input, select, button { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; }
|
word-break: break-all;
|
||||||
button { background: #6366f1; color: white; border: none; cursor: pointer; }
|
background: #f9fafb;
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
padding: 0.4rem;
|
||||||
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
|
border-radius: 4px;
|
||||||
.inline { display: inline; margin: 0; }
|
}
|
||||||
.color-input { width: 40px; height: 34px; padding: 0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; }
|
.invite-row {
|
||||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
display: flex;
|
||||||
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
|
gap: 0.5rem;
|
||||||
.modal h3 { margin: 0 0 0.75rem; }
|
align-items: center;
|
||||||
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
margin: 0.5rem 0;
|
||||||
.modal-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
|
}
|
||||||
.modal-actions button[type="button"] { background: #e5e7eb; color: #374151; }
|
.invite-row select {
|
||||||
.modal-actions button[type="submit"] { background: #dc2626; color: white; }
|
padding: 0.4rem;
|
||||||
.warning { color: #dc2626; font-size: 0.9rem; background: #fef2f2; padding: 0.6rem; border-radius: 6px; margin-bottom: 0.5rem; }
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.invite-row input {
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.payday-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.payday-form button[type='submit'] {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.payday-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.payday-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.payday-row label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.payday-row + .payday-row {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.qr {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.inline {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.color-input {
|
||||||
|
width: 40px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 440px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.modal-actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.modal-actions button[type='button'] {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.modal-actions button[type='submit'] {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+476
-43
@@ -3,6 +3,13 @@ import { serve } from "@hono/node-server";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { pb } from "./pb.ts";
|
import { pb } from "./pb.ts";
|
||||||
import { migrate } from "./migrate.ts";
|
import { migrate } from "./migrate.ts";
|
||||||
|
import {
|
||||||
|
weekStart as tzWeekStart,
|
||||||
|
addDaysStr,
|
||||||
|
todayInTz,
|
||||||
|
wallClockToUtc,
|
||||||
|
resolveTz,
|
||||||
|
} from "../../timezone.ts";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -15,12 +22,16 @@ function handleError(c: any, err: unknown) {
|
|||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
function weekStart(payday: number = 1): string {
|
function resolveServerTz(tz?: string): string {
|
||||||
const d = new Date();
|
return resolveTz(tz || "auto");
|
||||||
const day = d.getDay();
|
}
|
||||||
const diff = d.getDate() - ((day - payday + 7) % 7);
|
|
||||||
d.setDate(diff);
|
function weekStart(payday: number = 1, tz?: string): string {
|
||||||
return d.toISOString().slice(0, 10);
|
return tzWeekStart(payday, resolveServerTz(tz));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateStr: string, days: number): string {
|
||||||
|
return addDaysStr(dateStr, days);
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthStart(month?: string): string {
|
function monthStart(month?: string): string {
|
||||||
@@ -39,19 +50,17 @@ function monthEnd(month?: string): string {
|
|||||||
return `${month}-${String(lastDay).padStart(2, "0")}`;
|
return `${month}-${String(lastDay).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodStart(period: string, payday?: number): string {
|
function periodStart(period: string, payday?: number, tz?: string): string {
|
||||||
if (period === "daily") return new Date().toISOString().slice(0, 10);
|
if (period === "daily") return todayInTz(resolveServerTz(tz));
|
||||||
if (period === "weekly") return weekStart(payday);
|
if (period === "weekly") return weekStart(payday, tz);
|
||||||
if (period === "monthly") return monthStart();
|
if (period === "monthly") return monthStart();
|
||||||
return weekStart(payday);
|
return weekStart(payday, tz);
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodEnd(period: string, start: string): string {
|
function periodEnd(period: string, start: string): string {
|
||||||
if (period === "daily") return start;
|
if (period === "daily") return start;
|
||||||
if (period === "weekly") {
|
if (period === "weekly") {
|
||||||
const d = new Date(start);
|
return addDaysStr(start, 6);
|
||||||
d.setDate(d.getDate() + 6);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
}
|
}
|
||||||
if (period === "monthly") {
|
if (period === "monthly") {
|
||||||
const m = start.slice(0, 7);
|
const m = start.slice(0, 7);
|
||||||
@@ -70,6 +79,26 @@ async function getFamPayday(famId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getFamPaydayTime(famId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const fam = await pb.getList("fams", `id = '${famId}'`);
|
||||||
|
const t = fam.items?.[0]?.paydayTime;
|
||||||
|
return t || "18:00";
|
||||||
|
} catch {
|
||||||
|
return "18:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFamTimezone(famId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const fam = await pb.getList("fams", `id = '${famId}'`);
|
||||||
|
const tz = fam.items?.[0]?.timezone;
|
||||||
|
return tz || "auto";
|
||||||
|
} catch {
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Middleware ─────────────────────────────────────────────
|
// ── Middleware ─────────────────────────────────────────────
|
||||||
|
|
||||||
async function requireAdmin(c: any, next: any) {
|
async function requireAdmin(c: any, next: any) {
|
||||||
@@ -129,6 +158,7 @@ app.post("/api/admin/signup", async (c) => {
|
|||||||
slug,
|
slug,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
featureFlags: {},
|
featureFlags: {},
|
||||||
|
timezone: "auto",
|
||||||
});
|
});
|
||||||
const adminName = parentName || email.split("@")[0];
|
const adminName = parentName || email.split("@")[0];
|
||||||
const admin = await pb.create("fam_admins", {
|
const admin = await pb.create("fam_admins", {
|
||||||
@@ -515,6 +545,8 @@ app.get("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
|||||||
slug: fam.slug,
|
slug: fam.slug,
|
||||||
inviteCode: fam.inviteCode,
|
inviteCode: fam.inviteCode,
|
||||||
payday: fam.payday,
|
payday: fam.payday,
|
||||||
|
paydayTime: fam.paydayTime || "18:00",
|
||||||
|
timezone: fam.timezone || "auto",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
@@ -539,12 +571,32 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
|||||||
payday: record.payday,
|
payday: record.payday,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (body.payday !== undefined) {
|
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
|
||||||
const payday = Number(body.payday);
|
const patch: Record<string, string | number> = {};
|
||||||
if (payday < 0 || payday > 6 || !Number.isInteger(payday))
|
if (body.payday !== undefined) {
|
||||||
return c.json({ error: "payday must be 0-6" }, 400);
|
const payday = Number(body.payday);
|
||||||
const record = await pb.update("fams", famId, { payday });
|
if (payday < 0 || payday > 6 || !Number.isInteger(payday))
|
||||||
return c.json({ payday: record.payday });
|
return c.json({ error: "payday must be 0-6" }, 400);
|
||||||
|
patch.payday = payday;
|
||||||
|
}
|
||||||
|
if (body.paydayTime !== undefined) {
|
||||||
|
const paydayTime = String(body.paydayTime);
|
||||||
|
if (!/^\d{2}:\d{2}$/.test(paydayTime))
|
||||||
|
return c.json({ error: "paydayTime must be HH:MM" }, 400);
|
||||||
|
patch.paydayTime = paydayTime;
|
||||||
|
}
|
||||||
|
if (body.timezone !== undefined) {
|
||||||
|
const timezone = String(body.timezone);
|
||||||
|
if (timezone !== "auto" && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
|
||||||
|
return c.json({ error: "timezone must be an IANA name or 'auto'" }, 400);
|
||||||
|
patch.timezone = timezone;
|
||||||
|
}
|
||||||
|
const record = await pb.update("fams", famId, patch);
|
||||||
|
return c.json({
|
||||||
|
payday: record.payday,
|
||||||
|
paydayTime: record.paydayTime,
|
||||||
|
timezone: record.timezone,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return c.json({ error: "no valid fields" }, 400);
|
return c.json({ error: "no valid fields" }, 400);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -576,7 +628,8 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
|||||||
try {
|
try {
|
||||||
const { famId } = c.req.param();
|
const { famId } = c.req.param();
|
||||||
const payday = await getFamPayday(famId);
|
const payday = await getFamPayday(famId);
|
||||||
const ws = weekStart(payday);
|
const tz = await getFamTimezone(famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
const [members, assigned, completions] = await Promise.all([
|
const [members, assigned, completions] = await Promise.all([
|
||||||
pb.getList("members", `famId = '${famId}'`),
|
pb.getList("members", `famId = '${famId}'`),
|
||||||
pb.getList("assigned_chores", `famId = '${famId}'`),
|
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||||
@@ -587,7 +640,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
|||||||
try {
|
try {
|
||||||
const pointRewards = await pb.getList(
|
const pointRewards = await pb.getList(
|
||||||
"rewards",
|
"rewards",
|
||||||
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
|
`famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`,
|
||||||
);
|
);
|
||||||
rewardPointsList = pointRewards.items;
|
rewardPointsList = pointRewards.items;
|
||||||
} catch {} // schema may not have rewardType/status yet
|
} catch {} // schema may not have rewardType/status yet
|
||||||
@@ -649,12 +702,12 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
|||||||
return sum + (chore?.type === "money" ? Number(chore.value) : 0);
|
return sum + (chore?.type === "money" ? Number(chore.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Include claimed cash rewards in money earned
|
// Include claimed cash rewards in money earned (this week only)
|
||||||
let bonusMoney = 0;
|
let bonusMoney = 0;
|
||||||
try {
|
try {
|
||||||
const cashRewards = await pb.getList(
|
const cashRewards = await pb.getList(
|
||||||
"rewards",
|
"rewards",
|
||||||
`famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed'`,
|
`famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`,
|
||||||
);
|
);
|
||||||
bonusMoney = cashRewards.items.reduce(
|
bonusMoney = cashRewards.items.reduce(
|
||||||
(sum: number, r: any) => sum + Number(r.value),
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
@@ -682,6 +735,229 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Admin: Family settings (debug flag) ────────────────
|
||||||
|
|
||||||
|
async function getFamSettings(famId: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await pb.getList("settings", `famId = '${famId}'`)
|
||||||
|
).items?.[0] || {}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/admin/:famId/settings", requireAdmin, async (c) => {
|
||||||
|
try {
|
||||||
|
const { famId } = c.req.param();
|
||||||
|
const s = await getFamSettings(famId);
|
||||||
|
return c.json({
|
||||||
|
simulateEow: !!s.simulateEow,
|
||||||
|
webhookUrl: s.webhookUrl || "",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => {
|
||||||
|
try {
|
||||||
|
const { famId } = c.req.param();
|
||||||
|
const body = await c.req.json();
|
||||||
|
let s = await getFamSettings(famId);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (body.simulateEow !== undefined) patch.simulateEow = !!body.simulateEow;
|
||||||
|
if (body.webhookUrl !== undefined)
|
||||||
|
patch.webhookUrl = String(body.webhookUrl);
|
||||||
|
if (!s.id) {
|
||||||
|
s = await pb.create("settings", { famId, ...patch });
|
||||||
|
} else if (Object.keys(patch).length) {
|
||||||
|
s = await pb.update("settings", s.id, patch);
|
||||||
|
}
|
||||||
|
return c.json({ simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || "" });
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: EOW rollover preview (READ-ONLY) ──────────
|
||||||
|
|
||||||
|
app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
||||||
|
try {
|
||||||
|
const { famId } = c.req.param();
|
||||||
|
const payday = await getFamPayday(famId);
|
||||||
|
const tz = await getFamTimezone(famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
|
const we = periodEnd("weekly", ws);
|
||||||
|
|
||||||
|
const [members, assigned, completions, configs, rewards] =
|
||||||
|
await Promise.all([
|
||||||
|
pb.getList("members", `famId = '${famId}'`),
|
||||||
|
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||||
|
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
|
||||||
|
pb
|
||||||
|
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
|
||||||
|
.catch(() => ({ items: [] })),
|
||||||
|
pb.getList("rewards", `famId = '${famId}'`).catch(() => ({ items: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let rewardPointsList: any[] = [];
|
||||||
|
let rewardCashList: any[] = [];
|
||||||
|
try {
|
||||||
|
const [pw, cw] = await Promise.all([
|
||||||
|
pb.getList(
|
||||||
|
"rewards",
|
||||||
|
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
|
||||||
|
),
|
||||||
|
pb.getList(
|
||||||
|
"rewards",
|
||||||
|
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
rewardPointsList = pw.items;
|
||||||
|
rewardCashList = cw.items;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const assignedList = assigned.items;
|
||||||
|
const completionsList = completions.items;
|
||||||
|
|
||||||
|
const summaries = members.items.map((m: any) => {
|
||||||
|
const mc = completionsList.filter((c: any) => c.memberId === m.id);
|
||||||
|
const weekPoints = mc.reduce((sum: number, c: any) => {
|
||||||
|
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
const weekMoney = mc.reduce((sum: number, c: any) => {
|
||||||
|
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (ch?.type === "money" ? Number(ch.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
const bonusPoints = rewardPointsList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
const bonusMoney = rewardCashList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
return {
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name || m.username || m.id.slice(0, 6),
|
||||||
|
memberColor: m.color,
|
||||||
|
pointsEarned: weekPoints + bonusPoints,
|
||||||
|
moneyEarned: weekMoney + bonusMoney,
|
||||||
|
choresCompleted: mc.length,
|
||||||
|
bonusEarned: bonusPoints,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Predicted auto-created rewards (dry-run, never writes)
|
||||||
|
const predictedRewards: any[] = [];
|
||||||
|
const existingRewards = rewards.items || [];
|
||||||
|
for (const cfg of configs.items || []) {
|
||||||
|
if (cfg.type === "manual") continue;
|
||||||
|
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : "";
|
||||||
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : "";
|
||||||
|
const periodCompletions = cfg.period
|
||||||
|
? completionsList.filter(
|
||||||
|
(c: any) =>
|
||||||
|
(c.date || "").slice(0, 10) >= pStart &&
|
||||||
|
(c.date || "").slice(0, 10) <= pEnd,
|
||||||
|
)
|
||||||
|
: completionsList;
|
||||||
|
const cfgRewards = existingRewards.filter(
|
||||||
|
(r: any) => r.bonusConfigId === cfg.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tryEval = (target: any, sourceComps: any[]) => {
|
||||||
|
let current = 0;
|
||||||
|
if (cfg.type === "threshold")
|
||||||
|
current = sourceComps.reduce((sum: number, c: any) => {
|
||||||
|
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
else if (cfg.type === "count") current = sourceComps.length;
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cfg.target === "individual") {
|
||||||
|
const targets = cfg.memberId
|
||||||
|
? members.items.filter((m: any) => m.id === cfg.memberId)
|
||||||
|
: members.items;
|
||||||
|
for (const m of targets) {
|
||||||
|
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
||||||
|
const current = tryEval(m, periodCompletions.filter((c: any) => c.memberId === m.id));
|
||||||
|
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||||
|
predictedRewards.push({
|
||||||
|
config: cfg.name,
|
||||||
|
memberName: m.name || m.id.slice(0, 6),
|
||||||
|
type: cfg.rewardType,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (cfg.target === "collaborative") {
|
||||||
|
if (cfgRewards.length) continue;
|
||||||
|
const allIds = members.items.map((m: any) => m.id);
|
||||||
|
const teamComps = periodCompletions.filter((c: any) =>
|
||||||
|
allIds.includes(c.memberId),
|
||||||
|
);
|
||||||
|
const current = tryEval(cfg, teamComps);
|
||||||
|
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||||
|
predictedRewards.push({
|
||||||
|
config: cfg.name,
|
||||||
|
memberName: "Everyone",
|
||||||
|
type: cfg.rewardType,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`,
|
||||||
|
});
|
||||||
|
} else if (cfg.target === "competitive") {
|
||||||
|
if (cfgRewards.length) continue;
|
||||||
|
const scored: { memberId: string; name: any; current: number }[] =
|
||||||
|
members.items.map((m: any) => ({
|
||||||
|
memberId: m.id,
|
||||||
|
name: m.name,
|
||||||
|
current: tryEval(
|
||||||
|
cfg,
|
||||||
|
periodCompletions.filter((c: any) => c.memberId === m.id),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const qualified = scored.filter(
|
||||||
|
(st) => st.current >= Number(cfg.criteriaValue),
|
||||||
|
);
|
||||||
|
const eligible = qualified.length
|
||||||
|
? qualified
|
||||||
|
: scored.filter((st) => st.current > 0);
|
||||||
|
const winner = eligible.sort(
|
||||||
|
(aa, bb) => bb.current - aa.current,
|
||||||
|
)[0];
|
||||||
|
if (winner)
|
||||||
|
predictedRewards.push({
|
||||||
|
config: cfg.name,
|
||||||
|
memberName: winner.name,
|
||||||
|
type: cfg.rewardType,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
detail: `winner ${winner.current} pts`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All completions would reset on rollover (next week)
|
||||||
|
const nextWeekStart = addDays(ws, 7);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
simulateEow: true,
|
||||||
|
weekStart: ws,
|
||||||
|
weekEnd: we,
|
||||||
|
nextWeekStart,
|
||||||
|
summaries,
|
||||||
|
predictedRewards,
|
||||||
|
completionsThisWeek: completionsList.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Admin CRUD: Bonus Templates ────────────────────────
|
// ── Admin CRUD: Bonus Templates ────────────────────────
|
||||||
|
|
||||||
app.get("/api/admin/:famId/bonus-templates", requireAdmin, async (c) => {
|
app.get("/api/admin/:famId/bonus-templates", requireAdmin, async (c) => {
|
||||||
@@ -833,6 +1109,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
|
|||||||
try {
|
try {
|
||||||
const { famId } = c.req.param();
|
const { famId } = c.req.param();
|
||||||
const payday = await getFamPayday(famId);
|
const payday = await getFamPayday(famId);
|
||||||
|
const tz = await getFamTimezone(famId);
|
||||||
let configsData: any = { items: [] };
|
let configsData: any = { items: [] };
|
||||||
try {
|
try {
|
||||||
configsData = await pb.getList(
|
configsData = await pb.getList(
|
||||||
@@ -859,7 +1136,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
|
|||||||
const cfgRewards = allRewards.items.filter(
|
const cfgRewards = allRewards.items.filter(
|
||||||
(r: any) => r.bonusConfigId === cfg.id,
|
(r: any) => r.bonusConfigId === cfg.id,
|
||||||
);
|
);
|
||||||
const pStart = cfg.period ? periodStart(cfg.period, payday) : "";
|
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : "";
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : "";
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : "";
|
||||||
const periodCompletions = cfg.period
|
const periodCompletions = cfg.period
|
||||||
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
|
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
|
||||||
@@ -977,9 +1254,10 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
allRewards = await pb.getList("rewards", `famId = '${famId}'`);
|
allRewards = await pb.getList("rewards", `famId = '${famId}'`);
|
||||||
} catch {}
|
} catch {}
|
||||||
const paydayEval = await getFamPayday(famId);
|
const paydayEval = await getFamPayday(famId);
|
||||||
|
const tzEval = await getFamTimezone(famId);
|
||||||
|
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval) : "";
|
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : "";
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
|
||||||
const periodCompletions = cfg.period
|
const periodCompletions = cfg.period
|
||||||
? allCompletions.items.filter(
|
? allCompletions.items.filter(
|
||||||
@@ -999,25 +1277,40 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
? allMembers.items.filter((m: any) => m.id === cfg.memberId)
|
? allMembers.items.filter((m: any) => m.id === cfg.memberId)
|
||||||
: allMembers.items;
|
: allMembers.items;
|
||||||
for (const m of targetMembers) {
|
for (const m of targetMembers) {
|
||||||
if (existingRewards.find((r: any) => r.memberId === m.id)) continue;
|
|
||||||
const memberCompletions = periodCompletions.filter(
|
const memberCompletions = periodCompletions.filter(
|
||||||
(c: any) => c.memberId === m.id,
|
(c: any) => c.memberId === m.id,
|
||||||
);
|
);
|
||||||
let achieved = false;
|
let current = 0;
|
||||||
if (cfg.type === "threshold") {
|
if (cfg.type === "threshold") {
|
||||||
const total = memberCompletions.reduce((sum: number, c: any) => {
|
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
const chore = allAssigned.items.find(
|
const chore = allAssigned.items.find(
|
||||||
(a: any) => a.id === c.assignedChoreId,
|
(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);
|
||||||
achieved =
|
|
||||||
cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
|
|
||||||
} else if (cfg.type === "count") {
|
} else if (cfg.type === "count") {
|
||||||
achieved =
|
current = memberCompletions.length;
|
||||||
cfg.criteriaValue > 0 &&
|
|
||||||
memberCompletions.length >= Number(cfg.criteriaValue);
|
|
||||||
}
|
}
|
||||||
|
const achieved =
|
||||||
|
cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
||||||
|
|
||||||
|
const memberReward = existingRewards.find(
|
||||||
|
(r: any) => r.memberId === m.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up reward if threshold is no longer met (e.g. after revoke)
|
||||||
|
if (memberReward && !achieved) {
|
||||||
|
if (memberReward.status !== "claimed") {
|
||||||
|
try {
|
||||||
|
await pb.delete("rewards", memberReward.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already rewarded and still achieved
|
||||||
|
if (memberReward) continue;
|
||||||
|
|
||||||
if (achieved) {
|
if (achieved) {
|
||||||
const label =
|
const label =
|
||||||
cfg.rewardType === "cash"
|
cfg.rewardType === "cash"
|
||||||
@@ -1039,7 +1332,6 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (cfg.target === "collaborative") {
|
} else if (cfg.target === "collaborative") {
|
||||||
if (existingRewards.length > 0) continue;
|
|
||||||
const allMemberIds = allMembers.items.map((m: any) => m.id);
|
const allMemberIds = allMembers.items.map((m: any) => m.id);
|
||||||
const teamCompletions = periodCompletions.filter((c: any) =>
|
const teamCompletions = periodCompletions.filter((c: any) =>
|
||||||
allMemberIds.includes(c.memberId),
|
allMemberIds.includes(c.memberId),
|
||||||
@@ -1055,7 +1347,20 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
} else if (cfg.type === "count") {
|
} else if (cfg.type === "count") {
|
||||||
total = teamCompletions.length;
|
total = teamCompletions.length;
|
||||||
}
|
}
|
||||||
if (cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue)) {
|
const achieved =
|
||||||
|
cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
|
||||||
|
|
||||||
|
// Clean up collaborative rewards if threshold is no longer met
|
||||||
|
if (!achieved && existingRewards.length > 0) {
|
||||||
|
for (const r of existingRewards) {
|
||||||
|
if (r.status !== "claimed") {
|
||||||
|
try { await pb.delete("rewards", r.id); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (achieved && existingRewards.length === 0) {
|
||||||
for (const m of allMembers.items) {
|
for (const m of allMembers.items) {
|
||||||
const label =
|
const label =
|
||||||
cfg.rewardType === "cash"
|
cfg.rewardType === "cash"
|
||||||
@@ -1100,7 +1405,20 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
const eligible =
|
const eligible =
|
||||||
qualified.length > 0 ? qualified : scored.filter((s) => s.current > 0);
|
qualified.length > 0 ? qualified : scored.filter((s) => s.current > 0);
|
||||||
const winner = eligible.sort((a, b) => b.current - a.current)[0];
|
const winner = eligible.sort((a, b) => b.current - a.current)[0];
|
||||||
if (winner && !existingRewards.length) {
|
|
||||||
|
// Clean up competitive reward if no winner or winner changed
|
||||||
|
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.delete("rewards", existing.id); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner && existingRewards.length === 0) {
|
||||||
const label =
|
const label =
|
||||||
cfg.rewardType === "cash"
|
cfg.rewardType === "cash"
|
||||||
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
|
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
|
||||||
@@ -1139,6 +1457,7 @@ app.get("/api/admin/:famId/bonus-configs/tallies", requireAdmin, async (c) => {
|
|||||||
try {
|
try {
|
||||||
const { famId } = c.req.param();
|
const { famId } = c.req.param();
|
||||||
const payday = await getFamPayday(famId);
|
const payday = await getFamPayday(famId);
|
||||||
|
const tz = await getFamTimezone(famId);
|
||||||
const configs = await pb.getList("bonus_configs", `famId = '${famId}'`);
|
const configs = await pb.getList("bonus_configs", `famId = '${famId}'`);
|
||||||
const rewards = await pb.getList("rewards", `famId = '${famId}'`);
|
const rewards = await pb.getList("rewards", `famId = '${famId}'`);
|
||||||
|
|
||||||
@@ -1150,7 +1469,7 @@ app.get("/api/admin/:famId/bonus-configs/tallies", requireAdmin, async (c) => {
|
|||||||
let allTime = cfgRewards.length;
|
let allTime = cfgRewards.length;
|
||||||
let thisPeriod = allTime;
|
let thisPeriod = allTime;
|
||||||
if (cfg.period) {
|
if (cfg.period) {
|
||||||
const pStart = periodStart(cfg.period, payday);
|
const pStart = periodStart(cfg.period, payday, tz);
|
||||||
const pEnd = periodEnd(cfg.period, pStart);
|
const pEnd = periodEnd(cfg.period, pStart);
|
||||||
thisPeriod = cfgRewards.filter(
|
thisPeriod = cfgRewards.filter(
|
||||||
(r: any) => r.date >= pStart && r.date <= pEnd,
|
(r: any) => r.date >= pStart && r.date <= pEnd,
|
||||||
@@ -1223,7 +1542,8 @@ app.post(
|
|||||||
}
|
}
|
||||||
if (cfg.occurrence === "recurring" && cfg.period) {
|
if (cfg.occurrence === "recurring" && cfg.period) {
|
||||||
const payday = await getFamPayday(famId);
|
const payday = await getFamPayday(famId);
|
||||||
const pStart = periodStart(cfg.period, payday);
|
const tz = await getFamTimezone(famId);
|
||||||
|
const pStart = periodStart(cfg.period, payday, tz);
|
||||||
const pEnd = periodEnd(cfg.period, pStart);
|
const pEnd = periodEnd(cfg.period, pStart);
|
||||||
const periodRewards = memberRewards.filter(
|
const periodRewards = memberRewards.filter(
|
||||||
(r: any) => r.date >= pStart && r.date <= pEnd,
|
(r: any) => r.date >= pStart && r.date <= pEnd,
|
||||||
@@ -1368,6 +1688,8 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
|
|||||||
pb.getList("bonus_configs", `famId = '${famId}' && status = 'active'`),
|
pb.getList("bonus_configs", `famId = '${famId}' && status = 'active'`),
|
||||||
]);
|
]);
|
||||||
const payday = await getFamPayday(famId);
|
const payday = await getFamPayday(famId);
|
||||||
|
const paydayTime = await getFamPaydayTime(famId);
|
||||||
|
const timezone = await getFamTimezone(famId);
|
||||||
const rewardsList = rewards.items;
|
const rewardsList = rewards.items;
|
||||||
const configsList = bonusConfigs.items;
|
const configsList = bonusConfigs.items;
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -1376,6 +1698,9 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
|
|||||||
completions: completions.items,
|
completions: completions.items,
|
||||||
rewards: rewardsList,
|
rewards: rewardsList,
|
||||||
bonusConfigs: configsList,
|
bonusConfigs: configsList,
|
||||||
|
payday,
|
||||||
|
paydayTime,
|
||||||
|
timezone,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
@@ -1471,10 +1796,10 @@ app.post("/api/admin/:famId/rewards/issue-all", requireAdmin, async (c) => {
|
|||||||
const { memberId, message } = body;
|
const { memberId, message } = body;
|
||||||
if (!memberId) return c.json({ error: "memberId required" }, 400);
|
if (!memberId) return c.json({ error: "memberId required" }, 400);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Find all requested rewards for this member
|
// Find all claimable rewards for this member (unclaimed or requested)
|
||||||
const rewards = await pb.getList(
|
const rewards = await pb.getList(
|
||||||
"rewards",
|
"rewards",
|
||||||
`famId = '${famId}' && memberId = '${memberId}' && status = 'requested'`,
|
`famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`,
|
||||||
);
|
);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const r of rewards.items) {
|
for (const r of rewards.items) {
|
||||||
@@ -1689,13 +2014,121 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Payday: release this period's unpaid cash as an auto-claimed ask ──
|
||||||
|
|
||||||
|
async function releaseWeek(famId: string) {
|
||||||
|
const payday = await getFamPayday(famId);
|
||||||
|
const fams = await pb.getList("fams", `id = '${famId}'`);
|
||||||
|
const fam = fams.items?.[0];
|
||||||
|
if (!fam) throw new Error("Fam not found");
|
||||||
|
|
||||||
|
const tz = resolveTz(fam.timezone || "auto");
|
||||||
|
const wsToday = weekStart(payday, tz); // anchor date for the current payday period
|
||||||
|
|
||||||
|
// Payday fires at the configured time on the payday day. Before that moment
|
||||||
|
// (on the payday day itself) we hold off so the countdown can play out.
|
||||||
|
const paydayTime = fam.paydayTime || "18:00";
|
||||||
|
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
|
||||||
|
if (Date.now() < target.getTime()) {
|
||||||
|
return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
||||||
|
|
||||||
|
const members = await pb.getList("members", `famId = '${famId}'`);
|
||||||
|
let cashRewards: any = { items: [] };
|
||||||
|
try {
|
||||||
|
cashRewards = await pb.getList(
|
||||||
|
"rewards",
|
||||||
|
`famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`,
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const breakdown: any[] = [];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const today = now.slice(0, 10);
|
||||||
|
|
||||||
|
for (const m of members.items) {
|
||||||
|
const unpaid = (cashRewards.items || []).filter(
|
||||||
|
(r: any) => r.memberId === m.id && r.status !== "claimed",
|
||||||
|
);
|
||||||
|
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
|
||||||
|
// the parent's Issue list, but keep them as individual rows (Issue All totals).
|
||||||
|
for (const r of unpaid) {
|
||||||
|
if (r.status !== "requested") {
|
||||||
|
await pb.update("rewards", r.id, {
|
||||||
|
status: "requested",
|
||||||
|
claimedAt: null,
|
||||||
|
date: (r.date || "").slice(0, 10) || today,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Kid notice: the fun moment.
|
||||||
|
try {
|
||||||
|
await pb.create("notifications", {
|
||||||
|
famId,
|
||||||
|
memberId: m.id,
|
||||||
|
message: `You've earned £${total.toFixed(2)}! Go get it from your parent ⭐`,
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
breakdown.push({
|
||||||
|
memberId: m.id,
|
||||||
|
name: m.name,
|
||||||
|
total,
|
||||||
|
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pb.update("fams", famId, { lastIssued: wsToday });
|
||||||
|
return { settled: true, weekStart: wsToday, breakdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeFamReq(c: any): Promise<string | null> {
|
||||||
|
const sessFam = c.req.header("x-session-famid");
|
||||||
|
const sessUser = c.req.header("x-session-userid");
|
||||||
|
if (sessFam && sessUser) {
|
||||||
|
const admins = await pb.getList(
|
||||||
|
"fam_admins",
|
||||||
|
`famId = '${sessFam}' && userId = '${sessUser}'`,
|
||||||
|
);
|
||||||
|
if (admins.items?.length) return sessFam;
|
||||||
|
}
|
||||||
|
const devToken = c.req.header("x-device-token");
|
||||||
|
const devFam = c.req.header("x-device-famid");
|
||||||
|
if (devFam && devToken) {
|
||||||
|
const hashHex = crypto.createHash("sha256").update(devToken).digest("hex");
|
||||||
|
const members = await pb.getList(
|
||||||
|
"members",
|
||||||
|
`famId = '${devFam}' && deviceToken = '${hashHex}'`,
|
||||||
|
);
|
||||||
|
if (members.items?.length) return devFam;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/api/fam/:famId/payday", async (c) => {
|
||||||
|
try {
|
||||||
|
const famId = await authorizeFamReq(c);
|
||||||
|
if (!famId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
const result = await releaseWeek(famId);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Admin: Complete week (snapshot to weekly_history) ────
|
// ── Admin: Complete week (snapshot to weekly_history) ────
|
||||||
|
|
||||||
app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
|
app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
|
||||||
try {
|
try {
|
||||||
const { famId } = c.req.param();
|
const { famId } = c.req.param();
|
||||||
const payday = await getFamPayday(famId);
|
const payday = await getFamPayday(famId);
|
||||||
const ws = weekStart(payday);
|
const tz = await getFamTimezone(famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
|
|
||||||
// Evaluate weekly bonus configs first
|
// Evaluate weekly bonus configs first
|
||||||
await evaluateFam(famId);
|
await evaluateFam(famId);
|
||||||
@@ -1713,11 +2146,11 @@ app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
|
|||||||
const [pointsRewards, cashRewards] = await Promise.all([
|
const [pointsRewards, cashRewards] = await Promise.all([
|
||||||
pb.getList(
|
pb.getList(
|
||||||
"rewards",
|
"rewards",
|
||||||
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
|
`famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`,
|
||||||
),
|
),
|
||||||
pb.getList(
|
pb.getList(
|
||||||
"rewards",
|
"rewards",
|
||||||
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`,
|
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
rewardPointsList = pointsRewards.items;
|
rewardPointsList = pointsRewards.items;
|
||||||
|
|||||||
+178
-1
@@ -311,6 +311,27 @@ export async function migrate(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
console.log(` ↳ settings schema is current`);
|
console.log(` ↳ settings schema is current`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure simulateEow debug flag exists (read-only EOW preview toggle)
|
||||||
|
const simField = settingsCol.fields.some(
|
||||||
|
(f: any) => f.name === "simulateEow",
|
||||||
|
);
|
||||||
|
if (!simField) {
|
||||||
|
console.log("[migrate] Adding settings.simulateEow (debug toggle)...");
|
||||||
|
settingsCol.fields.push({ name: "simulateEow", type: "bool" });
|
||||||
|
await updateCollection(settingsCol.id, {
|
||||||
|
name: "settings",
|
||||||
|
type: "base",
|
||||||
|
listRule: settingsCol.listRule,
|
||||||
|
viewRule: settingsCol.viewRule,
|
||||||
|
createRule: settingsCol.createRule,
|
||||||
|
updateRule: settingsCol.updateRule,
|
||||||
|
deleteRule: settingsCol.deleteRule,
|
||||||
|
fields: settingsCol.fields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ settings.simulateEow already exists`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Create notifications collection if missing ──
|
// ── 4. Create notifications collection if missing ──
|
||||||
@@ -383,6 +404,119 @@ export async function migrate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 5b. Add lastIssued (payday heartbeat) field to fams if missing ──
|
||||||
|
{
|
||||||
|
const fc = famsCol || (await getCollection("fams"));
|
||||||
|
if (fc) {
|
||||||
|
const hasLastIssued = fc.fields.some((f: any) => f.name === "lastIssued");
|
||||||
|
if (!hasLastIssued) {
|
||||||
|
console.log("[migrate] Adding lastIssued field to fams...");
|
||||||
|
fc.fields.push({ name: "lastIssued", type: "text" });
|
||||||
|
await updateCollection(fc.id, {
|
||||||
|
name: "fams",
|
||||||
|
type: "base",
|
||||||
|
listRule: fc.listRule,
|
||||||
|
viewRule: fc.viewRule,
|
||||||
|
createRule: fc.createRule,
|
||||||
|
updateRule: fc.updateRule,
|
||||||
|
deleteRule: fc.deleteRule,
|
||||||
|
fields: fc.fields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ fams.lastIssued already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5c. Add paydayTime (HH:MM) field to fams if missing ──
|
||||||
|
{
|
||||||
|
const fc = famsCol || (await getCollection("fams"));
|
||||||
|
if (fc) {
|
||||||
|
const hasPaydayTime = fc.fields.some((f: any) => f.name === "paydayTime");
|
||||||
|
if (!hasPaydayTime) {
|
||||||
|
console.log("[migrate] Adding paydayTime field to fams...");
|
||||||
|
fc.fields.push({ name: "paydayTime", type: "text" });
|
||||||
|
await updateCollection(fc.id, {
|
||||||
|
name: "fams",
|
||||||
|
type: "base",
|
||||||
|
listRule: fc.listRule,
|
||||||
|
viewRule: fc.viewRule,
|
||||||
|
createRule: fc.createRule,
|
||||||
|
updateRule: fc.updateRule,
|
||||||
|
deleteRule: fc.deleteRule,
|
||||||
|
fields: fc.fields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ fams.paydayTime already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5d. Add timezone (IANA name or "auto") field to fams if missing ──
|
||||||
|
{
|
||||||
|
const fc = famsCol || (await getCollection("fams"));
|
||||||
|
if (fc) {
|
||||||
|
const hasTz = fc.fields.some((f: any) => f.name === "timezone");
|
||||||
|
if (!hasTz) {
|
||||||
|
console.log("[migrate] Adding timezone field to fams...");
|
||||||
|
fc.fields.push({
|
||||||
|
name: "timezone",
|
||||||
|
type: "text",
|
||||||
|
max: 64,
|
||||||
|
pattern: "",
|
||||||
|
autogeneratePattern: "",
|
||||||
|
primaryKey: false,
|
||||||
|
system: false,
|
||||||
|
required: false,
|
||||||
|
unique: false,
|
||||||
|
hidden: false,
|
||||||
|
presentable: false,
|
||||||
|
noDecimal: false,
|
||||||
|
});
|
||||||
|
await updateCollection(fc.id, {
|
||||||
|
name: "fams",
|
||||||
|
type: "base",
|
||||||
|
listRule: fc.listRule,
|
||||||
|
viewRule: fc.viewRule,
|
||||||
|
createRule: fc.createRule,
|
||||||
|
updateRule: fc.updateRule,
|
||||||
|
deleteRule: fc.deleteRule,
|
||||||
|
fields: fc.fields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ fams.timezone already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5e. Backfill fams.timezone = "auto" for any existing fams ──
|
||||||
|
try {
|
||||||
|
const t = await auth();
|
||||||
|
const res = await fetch(
|
||||||
|
`${PB_ENDPOINT}/api/collections/fams/records?perPage=200&filter=${encodeURIComponent(
|
||||||
|
`timezone = "" || timezone = null`,
|
||||||
|
)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${t}` } },
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const fams = data?.items || [];
|
||||||
|
for (const f of fams) {
|
||||||
|
await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${f.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${t}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ timezone: "auto" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fams.length) {
|
||||||
|
console.log(` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(" ↳ timezone backfill skipped:", err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ──
|
// ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ──
|
||||||
// PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date`
|
// PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date`
|
||||||
// and just normalise existing records. The frontend reads with .slice(0, 10) either way.
|
// and just normalise existing records. The frontend reads with .slice(0, 10) either way.
|
||||||
@@ -1164,5 +1298,48 @@ export async function migrate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[migrate] Done");
|
// ── 7. Add todo fields to assigned_chores if missing ──
|
||||||
|
const assignedCol = await getCollection("assigned_chores");
|
||||||
|
if (assignedCol) {
|
||||||
|
let needsUpdate = false;
|
||||||
|
|
||||||
|
// 7a. Make templateId non-required (todos don't use templates)
|
||||||
|
const tplField = assignedCol.fields.find((f: any) => f.name === "templateId");
|
||||||
|
if (tplField && tplField.required) {
|
||||||
|
console.log("[migrate] Making assigned_chores.templateId non-required...");
|
||||||
|
tplField.required = false;
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7b. Add isTodo, startDate, completeBy fields
|
||||||
|
const hasIsTodo = assignedCol.fields.some((f: any) => f.name === "isTodo");
|
||||||
|
if (!hasIsTodo) {
|
||||||
|
console.log("[migrate] Adding todo fields to assigned_chores...");
|
||||||
|
assignedCol.fields.push(
|
||||||
|
{ name: "isTodo", type: "bool" },
|
||||||
|
{ name: "startDate", type: "text" },
|
||||||
|
{ name: "completeBy", type: "text" }
|
||||||
|
);
|
||||||
|
needsUpdate = true;
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ assigned_chores.isTodo already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
await updateCollection(assignedCol.id, {
|
||||||
|
name: "assigned_chores",
|
||||||
|
type: "base",
|
||||||
|
listRule: assignedCol.listRule,
|
||||||
|
viewRule: assignedCol.viewRule,
|
||||||
|
createRule: assignedCol.createRule,
|
||||||
|
updateRule: assignedCol.updateRule,
|
||||||
|
deleteRule: assignedCol.deleteRule,
|
||||||
|
fields: assignedCol.fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[migrate] Done");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user