Compare commits
4 Commits
29d50985df
...
234b2cd8ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 234b2cd8ee | |||
| 78f456bf77 | |||
| 833a34049e | |||
| 39e432e52a |
@@ -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 {
|
||||||
|
|||||||
@@ -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,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)[] = [];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -171,30 +171,27 @@
|
|||||||
function todayPotentialFor(memberId: string) {
|
function todayPotentialFor(memberId: string) {
|
||||||
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
|
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
|
||||||
}
|
}
|
||||||
// Max points a full "all daily chores done" day is worth for a member.
|
// All-time completion rate for a member: all chores ever done ÷ (current
|
||||||
function dailyPointsPotential(memberId: string) {
|
// weekly potential × weeks active). Bounded and meaningful, unlike the old
|
||||||
return assigned
|
// points-per-day average (bonus points + weeklies done in one day could
|
||||||
.filter((a: any) => a.memberId === memberId && a.frequency === 'daily')
|
// push it past a "perfect day" — e.g. Jethro's 100%).
|
||||||
.reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0);
|
function overallRateFor(memberId: string): { done: number; total: number; pct: number } {
|
||||||
}
|
|
||||||
// All-time average daily points for a member, from the server-computed
|
|
||||||
// weekly series (all history, not just this week). Days active runs from
|
|
||||||
// their earliest recorded week through today (min 1).
|
|
||||||
function avgDailyPointsFor(memberId: string): number {
|
|
||||||
const line = trend.find((m: any) => m.memberId === memberId);
|
const line = trend.find((m: any) => m.memberId === memberId);
|
||||||
const weeks = line?.weeks || [];
|
const weeks = line?.weeks || [];
|
||||||
if (weeks.length === 0) return 0;
|
const done = weeks.reduce((sum: number, w: any) => sum + (Number(w.chores) || 0), 0);
|
||||||
const total = weeks.reduce((sum: number, w: any) => sum + (Number(w.points) || 0), 0);
|
const potential = weeklyPotentialFor(memberId);
|
||||||
|
if (weeks.length === 0 || potential <= 0) return { done, total: 0, pct: 0 };
|
||||||
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
|
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
|
||||||
const today = todayInTz(famTz);
|
const today = todayInTz(famTz);
|
||||||
const days = Math.max(
|
const weeksActive = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.round(
|
Math.floor(
|
||||||
(new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
|
(new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
|
||||||
86400000
|
(7 * 86400000)
|
||||||
) + 1
|
) + 1
|
||||||
);
|
);
|
||||||
return total / days;
|
const total = potential * weeksActive;
|
||||||
|
return { done, total, pct: Math.min(100, Math.round((done / total) * 100)) };
|
||||||
}
|
}
|
||||||
// Today's completion count — tolerant of UTC vs fam-local date keys.
|
// Today's completion count — tolerant of UTC vs fam-local date keys.
|
||||||
function todayDone(s: any): number {
|
function todayDone(s: any): number {
|
||||||
@@ -215,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' })
|
||||||
@@ -277,10 +275,11 @@
|
|||||||
}
|
}
|
||||||
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
|
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
|
||||||
if (scoreCanvas) {
|
if (scoreCanvas) {
|
||||||
|
const overall = overallRateFor(s.memberId);
|
||||||
donutCharts[`${s.memberId}:score`] = makeDonut(
|
donutCharts[`${s.memberId}:score`] = makeDonut(
|
||||||
scoreCanvas,
|
scoreCanvas,
|
||||||
avgDailyPointsFor(s.memberId),
|
overall.done,
|
||||||
dailyPointsPotential(s.memberId),
|
overall.total,
|
||||||
s.memberColor
|
s.memberColor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -335,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}
|
||||||
@@ -358,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>
|
||||||
|
|
||||||
@@ -413,9 +411,7 @@
|
|||||||
{@const tdTotal = todayPotentialFor(s.memberId)}
|
{@const tdTotal = todayPotentialFor(s.memberId)}
|
||||||
{@const tdDone = todayDone(s)}
|
{@const tdDone = todayDone(s)}
|
||||||
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
|
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
|
||||||
{@const scPot = dailyPointsPotential(s.memberId)}
|
{@const sc = overallRateFor(s.memberId)}
|
||||||
{@const scVal = avgDailyPointsFor(s.memberId)}
|
|
||||||
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
|
||||||
{@const goals = goalsByMember.get(s.memberId)}
|
{@const goals = goalsByMember.get(s.memberId)}
|
||||||
<div class="column col-member" style="--mc: {s.memberColor}">
|
<div class="column col-member" style="--mc: {s.memberColor}">
|
||||||
<div class="member-head">
|
<div class="member-head">
|
||||||
@@ -438,8 +434,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="donut-wrap">
|
<div class="donut-wrap">
|
||||||
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
|
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
|
||||||
<div class="donut-center" style="color:{s.memberColor}">{scPct}%</div>
|
<div class="donut-center" style="color:{s.memberColor}">{sc.pct}%</div>
|
||||||
<span class="donut-label">Avg/day</span>
|
<span class="donut-label">Overall</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-stats">
|
<div class="member-stats">
|
||||||
@@ -526,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
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": "1.11.0"
|
"version": "1.11.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user