some more improvements

This commit is contained in:
JCEEE
2026-09-15 15:14:18 +01:00
parent 833a34049e
commit 78f456bf77
8 changed files with 94 additions and 41 deletions
+8 -1
View File
@@ -5,6 +5,7 @@ import {
periodEnd, periodEnd,
nextPaydayAfter, nextPaydayAfter,
weekStart, weekStart,
weekdayInTz,
bonusWindow, bonusWindow,
completionInWindow completionInWindow
} from '@shared/timezone'; } from '@shared/timezone';
@@ -47,7 +48,13 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
const now = todayInTz(resolveServerTz(tz)); const now = todayInTz(resolveServerTz(tz));
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz); const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
const end = periodEnd(cfg.period, start); const end = periodEnd(cfg.period, start);
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) }; // Weeks close on payday, so a weekly window's end IS the settle date —
// nextPaydayAfter(end) would skip a whole extra week.
const settle =
weekdayInTz(new Date(end + 'T12:00:00Z'), tz) === payday
? end
: nextPaydayAfter(end, payday, tz);
return { claimable: 'payday', settleDate: settle };
} }
function targetChoreFor(cfg: any, memberId: string): string | undefined { function targetChoreFor(cfg: any, memberId: string): string | undefined {
+4 -1
View File
@@ -199,7 +199,10 @@ export async function releaseWeek(pb: any, famId: string) {
if (!fam) throw new Error('Fam not found'); if (!fam) throw new Error('Fam not found');
const wsToday = weekStart(payday, tz); const wsToday = weekStart(payday, tz);
const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz)); // Settlement lands on payday itself — the SIXTH day of the wsToday week
// (weeks close on payday), not on weekStart.
const paydayDate = addDaysStr(wsToday, 6);
const target = new Date(wallClockToUtc(paydayDate, paydayTime || '18:00', tz));
if (Date.now() < target.getTime()) { if (Date.now() < target.getTime()) {
return { return {
settled: false, settled: false,
+3 -2
View File
@@ -3,10 +3,11 @@ import { famMeta } from './fam';
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a // Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
// parametric form of `weekStart()` so we can bucket historical weeks, not // parametric form of `weekStart()` so we can bucket historical weeks, not
// just the current one. // just the current one. Weeks close on payday (open the day after).
function weekStartFor(dateStr: string, payday: number, tz: string): string { function weekStartFor(dateStr: string, payday: number, tz: string): string {
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz); const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
const back = (((wd - payday) % 7) + 7) % 7; const sincePayday = (((wd - payday) % 7) + 7) % 7;
const back = sincePayday === 0 ? 6 : sincePayday - 1;
return addDaysStr(dateStr, -back); return addDaysStr(dateStr, -back);
} }
+25 -8
View File
@@ -158,7 +158,32 @@ class FamStore {
async resync() { async resync() {
if (!this.initialized || this.destroyed || this.resyncing) return; if (!this.initialized || this.destroyed || this.resyncing) return;
this.resyncing = true; this.resyncing = true;
// Retry with backoff: a resync fired at the leading edge of a blip
// (QUIC drop, Tailscale relay flap) usually succeeds a second later.
// The dot stays amber until one attempt fully lands.
let lastErr: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt));
if (this.destroyed) break;
try { try {
await this.fetchAll();
if (this.destroyed) return;
// A completed resync proves the pipe is healthy again.
this.connectionDown = false;
lastErr = null;
break;
} catch (e) {
lastErr = e;
}
}
if (lastErr) {
console.error('FamStore.resync failed:', lastErr);
if (!this.destroyed) this.connectionDown = true;
}
this.resyncing = false;
}
private async fetchAll() {
const [ const [
membersRes, membersRes,
templatesRes, templatesRes,
@@ -203,14 +228,6 @@ class FamStore {
this.bonusTemplates = bonusTemplatesRes; this.bonusTemplates = bonusTemplatesRes;
this.rewards = rewardsRes; this.rewards = rewardsRes;
this.seasons = seasonsRes; this.seasons = seasonsRes;
// A completed resync proves the pipe is healthy again.
this.connectionDown = false;
} catch (e) {
console.error('FamStore.resync failed:', e);
if (!this.destroyed) this.connectionDown = true;
} finally {
this.resyncing = false;
}
} }
private connWatchers: (() => void)[] = []; private connWatchers: (() => void)[] = [];
+18
View File
@@ -2,8 +2,26 @@
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import NoticeDialog from '$lib/components/NoticeDialog.svelte'; import NoticeDialog from '$lib/components/NoticeDialog.svelte';
import { famStore } from '$lib/stores/fam.svelte';
let { children } = $props(); let { children } = $props();
// Last-resort net: PocketBase SDK fetch failures (the minified `send()`
// frame in prod stacks) otherwise surface as unhandled rejections with no
// UI signal. Network-shaped ones flip the TopNav status dot; the fam
// layout's resync clears it once the pipe is healthy again.
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const msg = String((e.reason as any)?.message || e.reason || '');
if (
/TypeError|Failed to fetch|Load failed|NetworkError|network|offline|ERR_QUIC|ERR_NETWORK|ERR_INTERNET/i.test(
msg
)
) {
famStore.connectionDown = true;
}
});
}
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
+19 -17
View File
@@ -212,6 +212,7 @@
if (!summary?.summaries?.length) return; if (!summary?.summaries?.length) return;
if (choresCanvas && summary.daysInWeek?.length) { if (choresCanvas && summary.daysInWeek?.length) {
// daysInWeek runs payday+1..payday (weeks close on payday).
const days = summary.daysInWeek; const days = summary.daysInWeek;
const labels = days.map((d: string) => const labels = days.map((d: string) =>
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' }) new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
@@ -333,19 +334,8 @@
subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`} subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`}
hero hero
> >
<!-- All-time family totals, large in the header --> <!-- Shared-computer toggle (left) + all-time totals (right), one line -->
<div class="vh-stats grow text-right"> <div class="vh-toprow">
<span class="vh-stat">
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
<span class="vh-lbl">points all time</span>
</span>
<span class="vh-stat">
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
<span class="vh-lbl">cash earned all time</span>
</span>
</div>
<!-- Shared-computer toggle, bottom-left of the hero -->
<div class="vh-foot">
<button <button
class="shared-hero-toggle" class="shared-hero-toggle"
class:on={sharedDevice.on} class:on={sharedDevice.on}
@@ -356,6 +346,16 @@
{@html monitorIcon} {@html monitorIcon}
<span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span> <span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span>
</button> </button>
<div class="vh-stats text-right">
<span class="vh-stat">
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
<span class="vh-lbl">points all time</span>
</span>
<span class="vh-stat">
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
<span class="vh-lbl">cash earned all time</span>
</span>
</div>
</div> </div>
</ViewHeader> </ViewHeader>
@@ -522,12 +522,14 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
opacity: 0.85; opacity: 0.85;
} }
/* Shared-computer toggle row, bottom-left of the hero */ /* Shared-computer toggle (left) + all-time totals (right), one line */
.vh-foot { .vh-toprow {
flex: 1 1 100%; flex: 1 1 100%;
display: flex; display: flex;
justify-content: flex-start; justify-content: space-between;
margin-top: 0.75rem; align-items: flex-end;
gap: 1rem;
margin-top: 0.5rem;
} }
.shared-hero-toggle { .shared-hero-toggle {
display: inline-flex; display: inline-flex;
@@ -294,7 +294,8 @@
Math.max( Math.max(
0, 0,
Math.round( Math.round(
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - // Payday closes the week on weekStart+6 (weeks run payday+1..payday).
(new Date(addDays(weekStart, 6) + 'T00:00:00').getTime() -
new Date(todayIso + 'T00:00:00').getTime()) / new Date(todayIso + 'T00:00:00').getTime()) /
86400000 86400000
) )
+5 -1
View File
@@ -53,7 +53,11 @@ export function addDaysStr(dateStr: string, days: number): string {
export function weekStart(payday: number, tz: string): string { export function weekStart(payday: number, tz: string): string {
const today = todayInTz(tz); const today = todayInTz(tz);
const wd = weekdayInTz(new Date(), tz); const wd = weekdayInTz(new Date(), tz);
const back = (((wd - payday) % 7) + 7) % 7; const sincePayday = (((wd - payday) % 7) + 7) % 7;
// The week CLOSES on payday, so it opens the day after: a payday itself
// belongs to the week ending that day (sincePayday 0 → back 6), e.g.
// payday Sunday → weeks run Monday..Sunday.
const back = sincePayday === 0 ? 6 : sincePayday - 1;
return addDaysStr(today, -back); return addDaysStr(today, -back);
} }