Skip to content

Commit 8208fdb

Browse files
authored
Merge pull request #3531 from Skords-01/claude/brave-cerf-18ffqe
2 parents 8dbdafe + 0ee0920 commit 8208fdb

12 files changed

Lines changed: 285 additions & 82 deletions

File tree

apps/web/src/modules/fizruk/components/dashboard/HeroCard.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,7 @@ function EmptyState({
447447
<p className="mt-2 text-sm text-teal-700 dark:text-white/75">
448448
{state.hasTemplates
449449
? "Нічого не заплановано — запусти готовий шаблон або відкрий програми."
450-
:
451-
"У тебе ще немає шаблонів. Створи свій перший або обери програму."}
450+
: "У тебе ще немає шаблонів. Створи свій перший або обери програму."}
452451
</p>
453452
<div className="mt-6 flex flex-col gap-3">
454453
{/*

apps/web/src/modules/fizruk/lib/sqliteReader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ export async function refreshFizrukSqliteState(
429429
dailyLog,
430430
monthlyPlan,
431431
workoutTemplates,
432+
// eslint-disable-next-line no-restricted-syntax -- cache-freshness stamp: UTC wall-clock instant, not a Kyiv day boundary
432433
refreshedAt: new Date().toISOString(),
433434
};
434435
return cache;
@@ -453,6 +454,7 @@ export function __setFizrukSqliteCacheForTests(
453454
): void {
454455
cache = {
455456
...EMPTY_CACHE,
457+
// eslint-disable-next-line no-restricted-syntax -- cache-freshness stamp: UTC wall-clock instant, not a Kyiv day boundary
456458
refreshedAt: new Date().toISOString(),
457459
...partial,
458460
};

apps/web/src/modules/fizruk/pages/Progress.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,11 @@ import { MiniLineChart } from "../components/MiniLineChart";
1414
import { WellbeingChart } from "../components/WellbeingChart";
1515
import { WeeklyVolumeChart } from "../components/WeeklyVolumeChart";
1616
import { epley1rm, weeklyVolumeSeriesNow } from "@sergeant/fizruk-domain";
17+
import { kyivMondayStartMs } from "@sergeant/shared";
1718
import { statusColors } from "@shared/charts";
1819
import { Card } from "@shared/components/ui/Card";
1920
import { SectionHeading } from "@shared/components/ui/SectionHeading";
2021
import { Stat } from "@shared/components/ui/Stat";
21-
import { getKyivWeekStart } from "@shared/lib/time/kyivTime";
22-
23-
function weekStartMs(d: number | string | Date) {
24-
// Domain-correct (Kyiv) week boundary so weekly-volume bars don't
25-
// shift when the user roams (consolidated page-audit § Theme 1 — 07 F1).
26-
const ts = typeof d === "number" ? d : new Date(d).getTime();
27-
return getKyivWeekStart(ts).getTime();
28-
}
2922

3023
// F36: minimum bar width (%) so the smallest muscle-volume bar stays
3124
// visible and tap-able even when its value is a tiny fraction of the max.
@@ -96,7 +89,9 @@ export function Progress({ onNavigate }: ProgressProps) {
9689
for (const w of workouts || []) {
9790
const t = w.startedAt ? Date.parse(w.startedAt) : NaN;
9891
if (!Number.isFinite(t) || t < cutoff) continue;
99-
const wk = weekStartMs(t);
92+
// Domain-correct (Kyiv) week boundary so weekly-volume bars don't
93+
// shift when the user roams (consolidated page-audit § Theme 1 — 07 F1).
94+
const wk = kyivMondayStartMs(t);
10095
if (!weeks.has(wk)) weeks.set(wk, {});
10196
const bucket = weeks.get(wk);
10297
if (!bucket) continue;

apps/web/src/shared/lib/time/kyivTime.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* @see docs/audits/2026-05-13-consolidated-page-audit.md § Theme 1
2020
*/
2121

22+
import { kyivMondayStartMs } from "@sergeant/shared";
23+
2224
const KYIV_TZ = "Europe/Kyiv";
2325

2426
/**
@@ -177,22 +179,14 @@ export function parseKyivDate(key: string): Date | null {
177179
* Monday-anchored week start (00:00 Kyiv local) for the week containing
178180
* `input`. Matches ISO-8601 week convention used by `date.getDay()`
179181
* with the Monday-first remapping `(getDay() + 6) % 7`.
182+
*
183+
* Delegates to the monorepo-wide `kyivMondayStartMs` so packages
184+
* (`fizruk-domain` weekly buckets) and web pages share one DST-safe
185+
* implementation — the previous local `dayStart − N×24h` step-back drifted
186+
* one hour on weeks containing a DST transition.
180187
*/
181188
export function getKyivWeekStart(input?: Date | number): Date {
182-
const { year, month, day, weekday } = getKyivDateParts(input);
183-
// `weekday` is 0=Sun..6=Sat; offset to Monday-anchored
184-
const mondayOffset = (weekday + 6) % 7;
185-
const dayKey = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
186-
const dayStart = parseKyivDate(dayKey);
187-
if (!dayStart) {
188-
// Should never happen — `getKyivDateParts` returned a valid date,
189-
// so `parseKyivDate` must accept it. Fall back to host-local midnight
190-
// to avoid throwing, but this is a hard logic error if it ever fires.
191-
const d = coerce(input);
192-
d.setHours(0, 0, 0, 0);
193-
return d;
194-
}
195-
return new Date(dayStart.getTime() - mondayOffset * 24 * 60 * 60 * 1000);
189+
return new Date(kyivMondayStartMs(coerce(input)));
196190
}
197191

198192
/**

packages/fizruk-domain/src/domain/dashboard/dashboardKpis.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,36 @@ describe("computeWeeklyTotals", () => {
128128
volumeKg: 250,
129129
});
130130
});
131+
132+
// Domain invariant: week boundaries are Europe/Kyiv, not the host tz.
133+
// Mon 2026-06-08 00:00 Kyiv (EEST, UTC+3) = 2026-06-07T21:00:00Z.
134+
it("anchors the week boundary to Europe/Kyiv regardless of host tz", () => {
135+
const now = new Date("2026-06-10T12:00:00Z"); // Wed of that week
136+
const workouts = [
137+
strengthWorkout("2026-06-07T20:30:00Z", [[100, 1]]), // Sun 23:30 Kyiv → out
138+
strengthWorkout("2026-06-07T21:30:00Z", [[60, 5]]), // Mon 00:30 Kyiv → in
139+
];
140+
expect(computeWeeklyTotals(workouts, now)).toEqual({
141+
count: 1,
142+
volumeKg: 300,
143+
});
144+
});
145+
146+
// DST week: Kyiv springs forward Sun 2026-03-29 03:00 EET → 04:00 EEST,
147+
// so this week is 167 hours long. A naive `weekStart + 7×24h` end bound
148+
// would leak the first hour of next Monday into the current week.
149+
it("keeps the 167-hour spring-forward DST week tight at both ends", () => {
150+
const now = new Date("2026-03-25T12:00:00Z"); // Wed of DST week
151+
const workouts = [
152+
strengthWorkout("2026-03-22T22:30:00Z", [[60, 5]]), // Mon 00:30 Kyiv (EET) → in
153+
strengthWorkout("2026-03-29T20:30:00Z", [[100, 1]]), // Sun 23:30 Kyiv (EEST) → in
154+
strengthWorkout("2026-03-29T21:30:00Z", [[999, 1]]), // Mon 00:30 Kyiv next week → out
155+
];
156+
expect(computeWeeklyTotals(workouts, now)).toEqual({
157+
count: 2,
158+
volumeKg: 60 * 5 + 100,
159+
});
160+
});
131161
});
132162

133163
describe("computeWeightChangeKg", () => {

packages/fizruk-domain/src/domain/dashboard/dashboardKpis.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
* window (default 30 days).
1818
*/
1919

20+
import { kyivMondayStartMs } from "@sergeant/shared";
21+
2022
import type {
2123
DashboardKpis,
2224
DashboardMeasurementInput,
@@ -42,14 +44,6 @@ function localYmdKey(ms: number): string {
4244
return `${y}-${m}-${day}`;
4345
}
4446

45-
function mondayStartMs(ms: number): number {
46-
const d = new Date(ms);
47-
d.setHours(0, 0, 0, 0);
48-
const dow = (d.getDay() + 6) % 7; // 0 = Mon
49-
d.setDate(d.getDate() - dow);
50-
return d.getTime();
51-
}
52-
5347
function isCompletedWorkout(
5448
w: DashboardWorkoutInput,
5549
): w is DashboardWorkoutInput & {
@@ -134,6 +128,7 @@ export function computeStreakDays(
134128

135129
/**
136130
* Current Mon-first-week counts (completed workouts + volume).
131+
* Week boundaries are anchored to Europe/Kyiv (domain invariant).
137132
*/
138133
export function computeWeeklyTotals(
139134
workouts: readonly DashboardWorkoutInput[] | null | undefined,
@@ -142,8 +137,13 @@ export function computeWeeklyTotals(
142137
const list = Array.isArray(workouts) ? workouts : [];
143138
if (list.length === 0) return { count: 0, volumeKg: 0 };
144139

145-
const weekStart = mondayStartMs(now.getTime());
146-
const weekEnd = weekStart + 7 * MS_PER_DAY;
140+
const weekStart = kyivMondayStartMs(now.getTime());
141+
// Не `weekStart + 7×24h`: DST-тиждень триває 167/169 год. Середина
142+
// наступного понеділка (±1h DST-люфт не виводить за межі дня) → його
143+
// київський старт.
144+
const weekEnd = kyivMondayStartMs(
145+
weekStart + 7 * MS_PER_DAY + MS_PER_DAY / 2,
146+
);
147147

148148
let count = 0;
149149
let volumeKg = 0;

packages/fizruk-domain/src/domain/workouts/exerciseDetail.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,46 @@ describe("computeExerciseBest", () => {
175175
});
176176

177177
describe("computeExerciseWeeklyTrend", () => {
178-
it("buckets sessions by local Monday and caps at 12 weeks", () => {
178+
// Domain invariant: week buckets are Europe/Kyiv-anchored.
179+
// Mon 2026-06-08 00:00 Kyiv (EEST, UTC+3) = 2026-06-07T21:00:00Z.
180+
it("splits Sun 23:30 vs Mon 00:30 (Kyiv) into separate week buckets", () => {
181+
const history = [
182+
{
183+
// Mon 2026-06-08 00:30 Kyiv → bucket 2026-06-08
184+
workout: w("w_mon", "2026-06-07T21:30:00Z", []),
185+
item: strength("i_mon", "squat", [{ weightKg: 60, reps: 5 }]),
186+
},
187+
{
188+
// Sun 2026-06-07 23:30 Kyiv → bucket 2026-06-01
189+
workout: w("w_sun", "2026-06-07T20:30:00Z", []),
190+
item: strength("i_sun", "squat", [{ weightKg: 100, reps: 1 }]),
191+
},
192+
];
193+
const { rmPoints } = computeExerciseWeeklyTrend(history);
194+
expect(rmPoints.map((p) => p.weekKey)).toEqual([
195+
"2026-06-01",
196+
"2026-06-08",
197+
]);
198+
// The label must show the Kyiv Monday's day-of-month even for users
199+
// west of Kyiv (Kyiv Mon 00:00 is still Sunday in UTC and westwards).
200+
expect(rmPoints[0]!.dateLabel).toMatch(/^1\s/);
201+
expect(rmPoints[1]!.dateLabel).toMatch(/^8\s/);
202+
});
203+
204+
// DST week: Kyiv springs forward Sun 2026-03-29 03:00 EET → 04:00 EEST.
205+
it("keeps the spring-forward DST Sunday inside its Kyiv week bucket", () => {
206+
const history = [
207+
{
208+
// Sun 2026-03-29 23:30 Kyiv (EEST) → bucket Mon 2026-03-23
209+
workout: w("w_dst", "2026-03-29T20:30:00Z", []),
210+
item: strength("i_dst", "squat", [{ weightKg: 80, reps: 3 }]),
211+
},
212+
];
213+
const { rmPoints } = computeExerciseWeeklyTrend(history);
214+
expect(rmPoints.map((p) => p.weekKey)).toEqual(["2026-03-23"]);
215+
});
216+
217+
it("buckets sessions by Kyiv Monday and caps at 12 weeks", () => {
179218
const history = [
180219
{
181220
// Wed 2026-04-08 groups with Mon 2026-04-06

packages/fizruk-domain/src/domain/workouts/exerciseDetail.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
* - cardio (distance) trend buckets (pace / distance),
1010
* - load-calculator zones (strength / hypertrophy / endurance).
1111
*
12-
* Kept DOM-free: no `window` / `localStorage` / `Intl.DateTimeFormat`
13-
* consumers here. Date labels are formatted via `Date#toLocaleDateString`
14-
* which is safe in Node and React Native runtimes.
12+
* Kept DOM-free: no `window` / `localStorage` consumers here. Date labels
13+
* are formatted via `Date#toLocaleDateString` (with an explicit
14+
* `Europe/Kyiv` timeZone — domain invariant) which is safe in Node and
15+
* React Native runtimes; week buckets come from the shared Kyiv-anchored
16+
* helpers in `@sergeant/shared`.
1517
*/
1618

19+
import { kyivMondayStartMs, toLocalISODate } from "@sergeant/shared";
20+
1721
import {
1822
epley1rm,
1923
suggestNextSet as suggestNextSetJs,
@@ -51,9 +55,16 @@ function toNum(v: unknown): number {
5155

5256
function formatUkDateShort(d: Date): string {
5357
try {
54-
return d.toLocaleDateString("uk-UA", { day: "numeric", month: "short" });
58+
// Explicit Kyiv timezone: week starts are Kyiv Monday 00:00, which is
59+
// still Sunday evening in UTC and westwards — rendering in the runtime
60+
// timezone would shift the label a day back for those users.
61+
return d.toLocaleDateString("uk-UA", {
62+
day: "numeric",
63+
month: "short",
64+
timeZone: "Europe/Kyiv",
65+
});
5566
} catch {
56-
// Fallback for minimal ICU runtimes — YYYY-MM-DD prefix.
67+
// Fallback for minimal ICU runtimes — host-local DD.MM (best effort).
5768
const pad = (n: number) => String(n).padStart(2, "0");
5869
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`;
5970
}
@@ -228,25 +239,7 @@ export function computeExerciseBest(
228239
}
229240

230241
/**
231-
* Start-of-Monday local-time timestamp for the given date (ms). Uses
232-
* the local tz, matching the web page's `getDay() - ((+6)%7)` trick.
233-
*/
234-
function mondayStartLocalMs(d: Date): number {
235-
const x = new Date(d);
236-
const offset = (x.getDay() + 6) % 7;
237-
x.setHours(0, 0, 0, 0);
238-
x.setDate(x.getDate() - offset);
239-
return x.getTime();
240-
}
241-
242-
function weekKeyFromMondayMs(ms: number): string {
243-
const d = new Date(ms);
244-
const pad = (n: number) => String(n).padStart(2, "0");
245-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
246-
}
247-
248-
/**
249-
* Group the strength history into Monday-starting weekly buckets and
242+
* Group the strength history into Monday-starting (Europe/Kyiv) weekly buckets and
250243
* return up to the **last 12** buckets as two aligned series:
251244
* - `rmPoints.value` = round(max Epley-1RM in the week, kg);
252245
* - `volPoints.value` = round(sum of `weightKg × reps`, kg).
@@ -266,8 +259,8 @@ export function computeExerciseWeeklyTrend(
266259
if (!iso) continue;
267260
const t = Date.parse(iso);
268261
if (!Number.isFinite(t)) continue;
269-
const weekStart = mondayStartLocalMs(new Date(t));
270-
const key = weekKeyFromMondayMs(weekStart);
262+
const weekStart = kyivMondayStartMs(t);
263+
const key = toLocalISODate(weekStart);
271264
const sets = Array.isArray(item.sets) ? item.sets : [];
272265
let maxRm = 0;
273266
let vol = 0;

packages/fizruk-domain/src/lib/workoutStats.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
33
completedWorkoutsCount,
4+
countCompletedInCurrentWeek,
45
formatCompactKg,
56
getExercisePR,
67
personalRecordsExerciseCount,
@@ -61,10 +62,79 @@ describe("personalRecordsExerciseCount", () => {
6162
});
6263

6364
describe("weeklyVolumeSeriesNow", () => {
65+
afterEach(() => {
66+
vi.useRealTimers();
67+
});
68+
6469
it("returns 7 volume slots", () => {
6570
const { volumeKg } = weeklyVolumeSeriesNow([]);
6671
expect(volumeKg).toHaveLength(7);
6772
});
73+
74+
function done(startedAt: string, weightKg: number, reps: number) {
75+
return {
76+
startedAt,
77+
endedAt: startedAt,
78+
items: [{ type: "strength", sets: [{ weightKg, reps }] }],
79+
};
80+
}
81+
82+
// Domain invariant: week boundaries are Europe/Kyiv, not the host tz.
83+
// Mon 2026-06-08 00:00 Kyiv (EEST, UTC+3) = 2026-06-07T21:00:00Z.
84+
it("anchors the Mon..Sun week to Europe/Kyiv regardless of host tz", () => {
85+
vi.useFakeTimers();
86+
vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); // Wed of that week
87+
88+
const { weekStartMs, volumeKg } = weeklyVolumeSeriesNow([
89+
done("2026-06-07T20:30:00Z", 100, 1), // Sun 23:30 Kyiv → previous week
90+
done("2026-06-07T21:30:00Z", 60, 5), // Mon 00:30 Kyiv → idx 0
91+
done("2026-06-10T10:00:00Z", 40, 10), // Wed 13:00 Kyiv → idx 2
92+
]);
93+
94+
expect(weekStartMs).toBe(Date.parse("2026-06-07T21:00:00Z"));
95+
expect(volumeKg).toEqual([300, 0, 400, 0, 0, 0, 0]);
96+
});
97+
98+
// DST week: Kyiv springs forward Sun 2026-03-29 03:00 EET → 04:00 EEST,
99+
// so the week Mon 2026-03-23 .. Sun 2026-03-29 is 167 hours long.
100+
it("buckets days correctly across the spring-forward DST week", () => {
101+
vi.useFakeTimers();
102+
vi.setSystemTime(new Date("2026-03-26T12:00:00Z")); // Thu of DST week
103+
104+
const { weekStartMs, volumeKg } = weeklyVolumeSeriesNow([
105+
done("2026-03-22T22:30:00Z", 60, 5), // Mon 00:30 Kyiv (EET) → idx 0
106+
done("2026-03-29T20:30:00Z", 100, 1), // Sun 23:30 Kyiv (EEST) → idx 6
107+
done("2026-03-29T21:30:00Z", 999, 1), // Mon 00:30 Kyiv next week → out
108+
]);
109+
110+
// Mon 2026-03-23 00:00 Kyiv (EET, UTC+2) = 2026-03-22T22:00:00Z.
111+
expect(weekStartMs).toBe(Date.parse("2026-03-22T22:00:00Z"));
112+
expect(volumeKg).toEqual([300, 0, 0, 0, 0, 0, 100]);
113+
});
114+
});
115+
116+
describe("countCompletedInCurrentWeek", () => {
117+
afterEach(() => {
118+
vi.useRealTimers();
119+
});
120+
121+
it("uses the Kyiv week boundary, not the host-local one", () => {
122+
vi.useFakeTimers();
123+
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
124+
125+
const mk = (startedAt: string) => ({
126+
startedAt,
127+
endedAt: startedAt,
128+
items: [],
129+
});
130+
expect(
131+
countCompletedInCurrentWeek([
132+
mk("2026-06-07T20:30:00Z"), // Sun 23:30 Kyiv → previous week
133+
mk("2026-06-07T21:30:00Z"), // Mon 00:30 Kyiv → this week
134+
mk("2026-06-12T10:00:00Z"), // Fri → this week
135+
]),
136+
).toBe(2);
137+
});
68138
});
69139

70140
describe("formatCompactKg", () => {

0 commit comments

Comments
 (0)