From 78f456bf77ff74c676d427410ef5505c4f2aba8c Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Tue, 15 Sep 2026 15:14:18 +0100 Subject: [PATCH] some more improvements --- frontend/src/lib/server/services/bonuses.ts | 9 +++- frontend/src/lib/server/services/fam.ts | 5 +- frontend/src/lib/server/services/stats.ts | 5 +- frontend/src/lib/stores/fam.svelte.ts | 53 ++++++++++++------- frontend/src/routes/+layout.svelte | 18 +++++++ frontend/src/routes/[fam]/+page.svelte | 36 +++++++------ .../src/routes/[fam]/[username]/+page.svelte | 3 +- shared/timezone.ts | 6 ++- 8 files changed, 94 insertions(+), 41 deletions(-) diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 3b3a19e..84f77c6 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -5,6 +5,7 @@ import { periodEnd, nextPaydayAfter, weekStart, + weekdayInTz, bonusWindow, completionInWindow } from '@shared/timezone'; @@ -47,7 +48,13 @@ function claimableStamp(cfg: any, payday: number, tz: string) { const now = todayInTz(resolveServerTz(tz)); const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz); const end = periodEnd(cfg.period, start); - return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) }; + // Weeks close on payday, so a weekly window's end IS the settle date — + // nextPaydayAfter(end) would skip a whole extra week. + const settle = + weekdayInTz(new Date(end + 'T12:00:00Z'), tz) === payday + ? end + : nextPaydayAfter(end, payday, tz); + return { claimable: 'payday', settleDate: settle }; } function targetChoreFor(cfg: any, memberId: string): string | undefined { diff --git a/frontend/src/lib/server/services/fam.ts b/frontend/src/lib/server/services/fam.ts index 12e1a18..0a6cb07 100644 --- a/frontend/src/lib/server/services/fam.ts +++ b/frontend/src/lib/server/services/fam.ts @@ -199,7 +199,10 @@ export async function releaseWeek(pb: any, famId: string) { if (!fam) throw new Error('Fam not found'); const wsToday = weekStart(payday, tz); - const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz)); + // Settlement lands on payday itself — the SIXTH day of the wsToday week + // (weeks close on payday), not on weekStart. + const paydayDate = addDaysStr(wsToday, 6); + const target = new Date(wallClockToUtc(paydayDate, paydayTime || '18:00', tz)); if (Date.now() < target.getTime()) { return { settled: false, diff --git a/frontend/src/lib/server/services/stats.ts b/frontend/src/lib/server/services/stats.ts index 28d5ed1..c67ddb4 100644 --- a/frontend/src/lib/server/services/stats.ts +++ b/frontend/src/lib/server/services/stats.ts @@ -3,10 +3,11 @@ import { famMeta } from './fam'; // Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a // parametric form of `weekStart()` so we can bucket historical weeks, not -// just the current one. +// just the current one. Weeks close on payday (open the day after). function weekStartFor(dateStr: string, payday: number, tz: string): string { const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz); - const back = (((wd - payday) % 7) + 7) % 7; + const sincePayday = (((wd - payday) % 7) + 7) % 7; + const back = sincePayday === 0 ? 6 : sincePayday - 1; return addDaysStr(dateStr, -back); } diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index 707441e..8d55738 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -158,7 +158,32 @@ class FamStore { async resync() { if (!this.initialized || this.destroyed || this.resyncing) return; this.resyncing = true; - try { + // Retry with backoff: a resync fired at the leading edge of a blip + // (QUIC drop, Tailscale relay flap) usually succeeds a second later. + // The dot stays amber until one attempt fully lands. + let lastErr: unknown = null; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt)); + if (this.destroyed) break; + try { + await this.fetchAll(); + if (this.destroyed) return; + // A completed resync proves the pipe is healthy again. + this.connectionDown = false; + lastErr = null; + break; + } catch (e) { + lastErr = e; + } + } + if (lastErr) { + console.error('FamStore.resync failed:', lastErr); + if (!this.destroyed) this.connectionDown = true; + } + this.resyncing = false; + } + + private async fetchAll() { const [ membersRes, templatesRes, @@ -194,23 +219,15 @@ class FamStore { Season[] > ]); - if (this.destroyed) return; - this.members = membersRes; - this.templates = templatesRes; - this.assigned = assignedRes; - this.completions = completionsRes; - this.bonusConfigs = bonusConfigsRes; - this.bonusTemplates = bonusTemplatesRes; - this.rewards = rewardsRes; - this.seasons = seasonsRes; - // A completed resync proves the pipe is healthy again. - this.connectionDown = false; - } catch (e) { - console.error('FamStore.resync failed:', e); - if (!this.destroyed) this.connectionDown = true; - } finally { - this.resyncing = false; - } + if (this.destroyed) return; + this.members = membersRes; + this.templates = templatesRes; + this.assigned = assignedRes; + this.completions = completionsRes; + this.bonusConfigs = bonusConfigsRes; + this.bonusTemplates = bonusTemplatesRes; + this.rewards = rewardsRes; + this.seasons = seasonsRes; } private connWatchers: (() => void)[] = []; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ab623b2..82ab70b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,8 +2,26 @@ import './layout.css'; import favicon from '$lib/assets/favicon.svg'; import NoticeDialog from '$lib/components/NoticeDialog.svelte'; + import { famStore } from '$lib/stores/fam.svelte'; let { children } = $props(); + + // Last-resort net: PocketBase SDK fetch failures (the minified `send()` + // frame in prod stacks) otherwise surface as unhandled rejections with no + // UI signal. Network-shaped ones flip the TopNav status dot; the fam + // layout's resync clears it once the pipe is healthy again. + if (typeof window !== 'undefined') { + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + const msg = String((e.reason as any)?.message || e.reason || ''); + if ( + /TypeError|Failed to fetch|Load failed|NetworkError|network|offline|ERR_QUIC|ERR_NETWORK|ERR_INTERNET/i.test( + msg + ) + ) { + famStore.connectionDown = true; + } + }); + } diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte index d60771c..8a6c630 100644 --- a/frontend/src/routes/[fam]/+page.svelte +++ b/frontend/src/routes/[fam]/+page.svelte @@ -212,6 +212,7 @@ if (!summary?.summaries?.length) return; if (choresCanvas && summary.daysInWeek?.length) { + // daysInWeek runs payday+1..payday (weeks close on payday). const days = summary.daysInWeek; const labels = days.map((d: string) => new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' }) @@ -333,19 +334,8 @@ subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`} hero > - -
- - {allTimePts.toLocaleString()} - points all time - - - £{allTimeCash.toFixed(2)} - cash earned all time - -
- -
+ +
+
+ + {allTimePts.toLocaleString()} + points all time + + + £{allTimeCash.toFixed(2)} + cash earned all time + +
@@ -522,12 +522,14 @@ letter-spacing: 0.08em; opacity: 0.85; } - /* Shared-computer toggle row, bottom-left of the hero */ - .vh-foot { + /* Shared-computer toggle (left) + all-time totals (right), one line */ + .vh-toprow { flex: 1 1 100%; display: flex; - justify-content: flex-start; - margin-top: 0.75rem; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-top: 0.5rem; } .shared-hero-toggle { display: inline-flex; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index b05f85d..72ff63a 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -294,7 +294,8 @@ Math.max( 0, Math.round( - (new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - + // Payday closes the week on weekStart+6 (weeks run payday+1..payday). + (new Date(addDays(weekStart, 6) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) / 86400000 ) diff --git a/shared/timezone.ts b/shared/timezone.ts index 6f14d12..7051506 100644 --- a/shared/timezone.ts +++ b/shared/timezone.ts @@ -53,7 +53,11 @@ export function addDaysStr(dateStr: string, days: number): string { export function weekStart(payday: number, tz: string): string { const today = todayInTz(tz); const wd = weekdayInTz(new Date(), tz); - const back = (((wd - payday) % 7) + 7) % 7; + const sincePayday = (((wd - payday) % 7) + 7) % 7; + // The week CLOSES on payday, so it opens the day after: a payday itself + // belongs to the week ending that day (sincePayday 0 → back 6), e.g. + // payday Sunday → weeks run Monday..Sunday. + const back = sincePayday === 0 ? 6 : sincePayday - 1; return addDaysStr(today, -back); }