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;
+ }
+ });
+ }