Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/lib/coach-demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// replaces at build time.

import { EXIDX, EXDB } from './exercises.js'
import { modeOf, workoutVolume } from './history.js'
import { modeOf, workoutVolume, workoutAt } from './history.js'
import { isWarmupRow } from './workout-model.js'
import { best1RM } from './onerm.js'
import { fmtNum } from './format.js'
Expand Down Expand Up @@ -156,7 +156,7 @@ function buildDebrief(S, workoutId) {
/** What "the room" would say on a busy instance — five people, plausible medians, your real bests. */
export function demoCohort(S) {
const since = Date.now() - 56 * 864e5
const you = Math.round((S.workouts || []).filter(w => (w.start || new Date(w.d).getTime()) > since).length / 8 * 10) / 10
const you = Math.round((S.workouts || []).filter(w => workoutAt(w) > since).length / 8 * 10) / 10
const ids = [...new Set((S.routines || []).flatMap(r => (r.ex || []).map(e => e.id)))].slice(0, 5)
const exercises = ids.map(id => {
const b = best1RM(S, id)
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/lib/coach-insights.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// window it names, which is why an old proposal in the thread still draws its chart months
// later, against the data as it was then.
import { EXIDX } from './exercises.js'
import { workoutVolume } from './history.js'
import { workoutVolume, workoutAt } from './history.js'
import { isWarmupRow } from './workout-model.js'
import { bestSetOf } from './onerm.js'

Expand All @@ -16,7 +16,6 @@ const median = arr => {
}
const bpOf = (S, id) => EXIDX[id]?.bp || (S.customEx || []).find(c => c.id === id)?.bp || null
const nameOf = (S, id) => EXIDX[id]?.n || (S.customEx || []).find(c => c.id === id)?.n || id
const tsOf = w => w.start || new Date(w.d + 'T12:00:00').getTime()

/** Workouts inside an inclusive ISO-date window; either bound may be missing. */
export function windowWorkouts(S, { from, to } = {}) {
Expand Down Expand Up @@ -61,7 +60,7 @@ export function insightsFor(S, win = {}, { topN = 3 } = {}) {
const best = bestSetOf(en)
if (!best) return
if (!series.has(en.id)) series.set(en.id, [])
series.get(en.id).push({ t: tsOf(w), d: w.d, y: Math.round(best.est * 10) / 10 })
series.get(en.id).push({ t: workoutAt(w), d: w.d, y: Math.round(best.est * 10) / 10 })
}))
const strength = [...series.entries()]
.filter(([, pts]) => pts.length >= 2)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/effort.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// half-empty series. So everything aggregates in RIR and is converted back for display.
// RIR is the internal unit because it has a real zero — a set taken to failure — where RPE's
// floor of 6 is only a convention about which sets are worth rating. RPE 8 == RIR 2.
import { EFFORT, effortOf } from './history.js'
import { EFFORT, effortOf, workoutAt } from './history.js'
import { weekKey, weekStartOf, startOfWeek } from './format.js'
import { isWarmupRow } from './workout-model.js'

Expand Down Expand Up @@ -51,7 +51,7 @@ function eachDoneSet(S, fn) {
// A window in days, counted back from now. 0 = everything, which is also what an empty
// history means for every caller here.
const inWindow = (w, days) =>
!days || (w.start || new Date(w.d).getTime()) > Date.now() - days * 86400000
!days || workoutAt(w) > Date.now() - days * 86400000

export const avgRir = sets => {
const vs = (sets || []).map(rirOf).filter(v => v != null)
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/lib/exercise-history.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { metricEntriesForExercise, metricRowsForEntry, bestWeightForEntry, completedRepsOf, modeOf } from './history.js'
import { metricEntriesForExercise, metricRowsForEntry, bestWeightForEntry, completedRepsOf, modeOf, workoutAt } from './history.js'
import { completedVolumeOf } from './workout-model.js'
import { bestSetOf } from './onerm.js'
import { beatsWeight } from './exercises.js'
Expand All @@ -21,8 +21,6 @@ import { beatsWeight } from './exercises.js'

export const HISTORY_SESSIONS = 10

const startOf = w => (Number.isFinite(w.start) ? w.start : new Date(w.d + 'T12:00:00').getTime())

// Volume of the exercise in one session: main set plus its drops/bursts, reps mode only —
// there is no honest tonnage for a hold or a run.
const entryVolume = rows => rows.reduce((v, s) => v + completedVolumeOf(s), 0)
Expand All @@ -43,7 +41,7 @@ export function exerciseHistory(S, exId, { limit = HISTORY_SESSIONS } = {}) {
const en = { ...sameMode.at(-1).entry, sets: rows }
if (rows.length) logged.push({ w, en, mode, rows })
})
logged.sort((a, b) => startOf(a.w) - startOf(b.w))
logged.sort((a, b) => workoutAt(a.w) - workoutAt(b.w))

const empty = { mode: modeOf({ id: exId }), metric: 'weight', best: 0, prId: null, total: 0, sessions: [], points: [], e1rmPoints: [] }
if (!logged.length) return empty
Expand All @@ -64,7 +62,7 @@ export function exerciseHistory(S, exId, { limit = HISTORY_SESSIONS } = {}) {
const same = m === mode
const value = same ? valueOf({ en, rows }) : null
const e1rm = m === 'reps' ? (bestSetOf(en)?.est ?? null) : null
const t = startOf(w)
const t = workoutAt(w)
// "PR" goes on the session that first reached the all-time best, not on every session
// that later matched it — one marker says where the record was set.
if (value != null && value > best) { best = value; prId = w.id }
Expand Down Expand Up @@ -120,7 +118,7 @@ export function bestSetFor(S, exId, mode = modeOf({ id: exId })) {
for (const en of w.entries || []) {
if (en.id !== exId) continue
for (const set of metricRowsForEntry(en, mode)) {
t ??= startOf(w)
t ??= workoutAt(w)
if (!best || better(set, best.set) || (!better(best.set, set) && t < best.t)) best = { d: w.d, set, target: en.target || null, t }
}
}
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/lib/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ import { t } from './i18n-core.js'
import { queueNext, pinState, queueLiveOn } from './queue.js'
import { isPyramid, pyramidLabel, pyramidTargetAt, PYRAMID_MAX } from './pyramid.js'

// When a workout happened, as epoch ms: its own recorded start, else noon on its calendar day.
// Noon rather than midnight because `new Date('2026-09-22')` parses as UTC midnight, which any
// negative UTC offset drags back into the day before; noon survives every zone and DST shift.
// Several copies of this rule had drifted apart — some onto UTC midnight, some onto `||`, which
// throws away a legitimate start of 0 — and readers that feed the strength/recovery decay off it
// dated start-less history up to 14 h out, in a direction set by the reader's timezone. NaN when
// the workout carries neither a start nor a usable day.
export const workoutAt = w => {
if (Number.isFinite(w?.start)) return w.start
const day = w?.d
return typeof day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(day)
? new Date(day + 'T12:00:00').getTime()
: NaN
}

// How an exercise is logged (issue #16). This used to be derived from the body part alone,
// which meant a plank or a farmer's carry could only be timed by filing it under cardio.
// A routine entry can now say so explicitly:
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/muscles.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isWarmupRow } from './workout-model.js'
import { EXIDX, smOf } from './exercises.js'
import { todayISO, weekKey, MONDAY } from './format.js'
import { queueOf } from './queue.js'
import { workoutAt } from './history.js'

// The muscles a map can shade, in head-to-toe order — also the order of any list
// built from them, so "what am I neglecting" reads top-down like a body.
Expand Down Expand Up @@ -286,7 +287,7 @@ export function muscleBalanceWindow(workouts, win, now = Date.now(), today = tod
? true
: win === 7
? weekKey(workout.d, ws) === weekKey(today, ws)
: (workout.start || new Date(workout.d).getTime()) > now - win * 86400000)
: workoutAt(workout) > now - win * 86400000)
}

/** Load a routine *would* produce, from its planned set counts. */
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/lib/recovery.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EXIDX } from './exercises.js'
import { workoutAt } from './history.js'
import { MUSCLES, musclesOf } from './muscles.js'
import { isWarmupRow, dropsOf } from './workout-model.js'

Expand Down Expand Up @@ -50,12 +51,11 @@ export function halfLifeDecay(ageMs, halfLifeMs) {
return 0.5 ** (ageMs / halfLifeMs)
}

// The v2 data contract has one timestamp per workout, not per set. Keep this fallback in one
// place so fatigue and strength use exactly the same stimulus time as effort.js.
function workoutTimestamp(workout) {
const timestamp = workout?.start || new Date(workout?.d).getTime()
return Number.isFinite(timestamp) ? timestamp : Number(timestamp)
}
// The v2 data contract has one timestamp per workout, not per set. history.js owns the rule
// (workoutAt) so fatigue, strength, effort.js and Stats all read the same stimulus time: a
// start of 0 is a real timestamp, and a date-only workout is local noon rather than UTC
// midnight, which is the previous day west of Greenwich.
const workoutTimestamp = workoutAt

function emptyMuscleMap(value) {
return Object.fromEntries(MUSCLES.map(slug => [slug, value]))
Expand Down
15 changes: 5 additions & 10 deletions frontend/src/lib/strength-exercises.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// catalogue-first (EXIDX), exactly like the fatigue/strength maps, falling back to the
// logged snapshot (muscleWeights) for exercises no longer in the catalogue.
import { best1RM } from './onerm.js'
import { entriesForExercise } from './history.js'
import { entriesForExercise, workoutAt } from './history.js'
import { STRENGTH_FULL_MS, STRENGTH_HALF_LIFE_MS, STRENGTH_FLOOR, halfLifeDecay } from './recovery.js'
import { musclesOf } from './muscles.js'
import { EXIDX } from './exercises.js'
Expand All @@ -28,7 +28,7 @@ function strengthFromAge(ageMs) {
function lastWorkSetAt(S, id) {
let latest = -Infinity
for (const workout of S?.workouts || []) {
const ts = workout.start || new Date(workout.d).getTime()
const ts = workoutAt(workout)
if (!Number.isFinite(ts) || ts <= latest) continue
const entries = entriesForExercise(workout, id)
if (entries.some(entry => (entry.sets || []).some(s => hasCompletedWork(s) && !isWarmupRow(s)))) latest = ts
Expand Down Expand Up @@ -76,19 +76,14 @@ function workoutDay(workout) {
return typeof day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null
}

function workoutTimestamp(workout) {
if (Number.isFinite(workout?.start)) return workout.start
const day = workoutDay(workout)
const timestamp = day ? new Date(day + 'T12:00:00').getTime() : NaN
return Number.isFinite(timestamp) ? timestamp : -Infinity
}

function entriesWithId(S, id) {
let order = 0
return (S?.workouts || []).flatMap(workout => entriesForExercise(workout, id).map(entry => ({
entry,
day: workoutDay(workout),
timestamp: workoutTimestamp(workout),
// -Infinity, not NaN: isNewerOccurrence compares these with `!==` then `>`, and a NaN pair
// would answer "not equal, not greater" instead of falling through to insertion order.
timestamp: Number.isFinite(workoutAt(workout)) ? workoutAt(workout) : -Infinity,
order: order++,
})))
}
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/lib/workout-at.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { workoutAt } from './history.js'
import { muscleBalanceWindow } from './muscles.js'

// Several copies of "when did this workout happen" had drifted apart, and the two ways they were
// wrong are the two things worth pinning: the UTC-midnight parse, and `||` swallowing a start
// of 0. Everything else in the app dates a day at local noon so that no timezone or DST shift
// can move it, and this is the one function that decides it now.
describe('workoutAt', () => {
it('prefers the recorded start', () => {
expect(workoutAt({ start: 1_700_000_000_000, d: '2026-09-22' })).toBe(1_700_000_000_000)
})

it('keeps a start of 0 instead of falling through to the day', () => {
// Several of the copies wrote `w.start || ...`, so the first instant of 1970 read as
// "no start recorded" and silently became noon on the workout's calendar day.
expect(workoutAt({ start: 0, d: '2026-09-22' })).toBe(0)
})

it('dates a start-less workout at noon on its own calendar day', () => {
const at = new Date(workoutAt({ d: '2026-09-22' }))
expect([at.getFullYear(), at.getMonth() + 1, at.getDate()]).toEqual([2026, 9, 22])
expect(at.getHours()).toBe(12)
})

it('does not land on the day before, the way a bare Date(iso) does west of UTC', () => {
const iso = '2026-09-22'
const utcMidnight = new Date(iso).getTime()
const ours = workoutAt({ d: iso })
expect(new Date(ours).getDate()).toBe(22)
// Only west of UTC does the old parse actually cross the boundary, so assert the bug
// itself only where it bites. The noon rule above holds in every zone either way.
if (new Date(iso + 'T12:00:00').getTimezoneOffset() > 0) {
expect(new Date(utcMidnight).getDate()).toBe(21)
expect(ours).toBeGreaterThan(utcMidnight)
}
})

it('answers NaN when the workout carries neither a start nor a usable day', () => {
for (const w of [{}, null, undefined, { d: '' }, { d: 'yesterday' }, { d: '2026-9-2' }, { start: 'x' }]) {
expect(Number.isNaN(workoutAt(w))).toBe(true)
}
})
})

describe('muscleBalanceWindow dates its cutoff the same way', () => {
it('keeps a workout logged today inside a 30-day window', () => {
// The UTC-midnight parse put a start-less workout up to 12 h earlier than it happened, so
// a workout right on the edge of the window fell out of it depending on the reader's zone.
const now = new Date('2026-09-22T12:00:00').getTime()
const kept = muscleBalanceWindow([{ d: '2026-09-22' }], 30, now)
expect(kept).toHaveLength(1)
})

it('still drops one that predates the window', () => {
const now = new Date('2026-09-22T12:00:00').getTime()
expect(muscleBalanceWindow([{ d: '2026-07-01' }], 30, now)).toHaveLength(0)
})
})

/* Several call sites had their own copy of the rule: Stats.jsx twice, recovery.js, effort.js,
coach-demo.js, coach-insights.js, muscles.js, strength-exercises.js and the MCP server's
muscle_balance. A grep is the only guard that would have caught that, so here it is. */
describe('nothing re-implements the rule', () => {
it('no source file dates a workout by hand', async () => {
const fs = await import('node:fs')
const path = await import('node:path')
const root = path.resolve(import.meta.dirname, '..')
const mcp = path.resolve(root, '../../mcp/src') // imports these helpers too
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
const p = path.join(dir, e.name)
if (e.isDirectory()) return e.name === 'locales' ? [] : walk(p)
return /\.(js|jsx)$/.test(e.name) && !/\.test\.(js|jsx)$/.test(e.name) ? [p] : []
})
// `w.start || new Date(w.d)` and its spellings. history.js itself is where the rule lives.
const handRolled = /\bstart\s*(\|\||\?\?)\s*(Number\()?new Date\s*\(/
const offenders = [...walk(root), ...walk(mcp)]
.filter(p => !p.endsWith(path.join('lib', 'history.js')))
.filter(p => handRolled.test(fs.readFileSync(p, 'utf8')))
.map(p => path.relative(root, p))
expect(offenders).toEqual([])
})
})

/* What the sites were getting wrong in practice. `new Date('2026-09-22')` is UTC midnight,
which is 20:00 the previous day in New York, so a date-only workout logged today was already
four hours old to every window filter and could fall out of one early. */
describe('a date-only workout against a window edge', () => {
it('stays inside a window that a UTC-midnight parse would push it out of', () => {
const iso = '2026-09-22'
// A window that opened at 06:00 local on the workout's own day.
const opened = new Date('2026-09-22T06:00:00').getTime()
expect(workoutAt({ d: iso })).toBeGreaterThan(opened)
expect(new Date(iso).getTime()).toBeLessThan(opened) // what the hand-rolled copies computed
})
})
6 changes: 3 additions & 3 deletions frontend/src/views/Stats.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useStore } from '../store/useStore.js'
import { EXIDX, matchExercise, betterWeight } from '../lib/exercises.js'
import { lastBW, streakWeeks, setLabel, modeOf, effortOf, entriesForExercise, metricEntriesForExercise, metricModeForEntry, bestWeightForEntry, completedRepsOf, workoutDay } from '../lib/history.js'
import { lastBW, streakWeeks, setLabel, modeOf, effortOf, entriesForExercise, metricEntriesForExercise, metricModeForEntry, bestWeightForEntry, completedRepsOf, workoutDay, workoutAt } from '../lib/history.js'
import { fmtNum, fmtDate, fmtVol, todayISO, isoOf, weekKey, weekStartOf, exerciseNameText } from '../lib/format.js'
import { speedUnitOf, speedLabel, toSpeed } from '../lib/speed.js'
import { t, exerciseNameFor, exerciseNameClass, getLang } from '../lib/i18n.js'
Expand Down Expand Up @@ -30,7 +30,7 @@ import { isWarmupRow } from '../lib/workout-model.js'
function latestMuscleTraining(workouts) {
const latest = {}
for (const workout of workouts || []) {
const timestamp = Number(workout?.start || new Date(workout?.d).getTime())
const timestamp = workoutAt(workout)
if (!Number.isFinite(timestamp)) continue
for (const entry of workout.entries || []) {
if (!(entry.sets || []).some(set => set?.done === true && !isWarmupRow(set))) continue
Expand Down Expand Up @@ -147,7 +147,7 @@ function MuscleBalance({ S }) {
const comparisonMuscles = MUSCLES
.filter(muscle => (planned[muscle] || 0) > 0 || (load[muscle] || 0) > 0)
.sort((a, b) => (load[b] || 0) - (load[a] || 0) || (planned[b] || 0) - (planned[a] || 0) || MUSCLES.indexOf(a) - MUSCLES.indexOf(b))
const volWin = S.workouts.filter(w => (w.start || new Date(w.d).getTime()) > now - 90 * 86400000)
const volWin = S.workouts.filter(w => workoutAt(w) > now - 90 * 86400000)
const vol90 = loadOfWorkouts(volWin, null)
const { worked, missed } = rankOf(load)
const { worked: strengthOrder } = rankOf(strength)
Expand Down
4 changes: 2 additions & 2 deletions mcp/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
fmt, setLabel, exLine, muscleName, policyName, friendlyDuration, ratio, muscleOrder
} from './labels.js'
import {
modeOf, workoutVolume, setsDone, effectiveRoutine, effectiveRoutineIds, lastEntryFor
modeOf, workoutVolume, setsDone, effectiveRoutine, effectiveRoutineIds, lastEntryFor, workoutAt
} from '../../frontend/src/lib/history.js'
import { queueView, queueNext, pinState } from '../../frontend/src/lib/queue.js'
import { exOr } from '../../frontend/src/lib/exercises.js'
Expand Down Expand Up @@ -466,7 +466,7 @@ export const muscleBalance = {
const cutoff = period === 'week' ? midnightDaysBack(6)
: period === 'month' ? midnightDaysBack(29)
: Number.NEGATIVE_INFINITY
const workouts = (S.workouts || []).filter(w => (w.start || new Date(w.d + 'T12:00:00').getTime()) >= cutoff)
const workouts = (S.workouts || []).filter(w => workoutAt(w) >= cutoff)
// loadOf() resolves each entry through EXIDX, which holds the catalogue only, so a
// custom exercise's sets score zero here. Attaching the custom itself lets loadOf's own
// `historical` branch resolve it. Only a *found* custom: exOr's miss placeholder carries
Expand Down
Loading