mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
/**
|
||
* Window-trend helpers for the health-chart `showTrend` overlay.
|
||
*
|
||
* Two complementary deltas:
|
||
*
|
||
* - `weeklyDelta` — first vs last regression-line value, normalised
|
||
* to a per-week rate. Stable for short windows
|
||
* (≤ 90 days) where the user is asking "what is
|
||
* this metric doing this week / month".
|
||
* - `splitHalfDelta` — mean of the second half minus mean of the first
|
||
* half of the visible series. Stays meaningful
|
||
* for long ("All") ranges where the per-week rate
|
||
* rounds to ±0 in the 1-decimal formatter.
|
||
*
|
||
* v1.4.16 — the maintainer reported the chart trend overlay reads
|
||
* "+0.0 kg/Woche" on the All filter, even when their weight clearly
|
||
* shifted across years. Root cause: `weeklyDelta = (last − first) / days
|
||
* × 7` is mathematically correct but visually rounds to zero whenever
|
||
* the slope-per-day is small enough that 7× still falls below the
|
||
* formatter's display precision. The split-half delta side-steps the
|
||
* rounding entirely because it doesn't divide by the window width.
|
||
*
|
||
* Pure & deterministic — pinned by unit test.
|
||
*/
|
||
|
||
export interface WindowTrendInput {
|
||
/** Sorted ascending by timestamp. */
|
||
rawValues: number[];
|
||
/** Sorted ascending by timestamp. Must align 1:1 with `rawValues`. */
|
||
trendValues: number[];
|
||
/** Days between the first and last point of the visible series. */
|
||
windowDays: number;
|
||
}
|
||
|
||
export interface WindowTrendResult {
|
||
/** Linear-trend per-week delta. Always present when ≥ 2 points. */
|
||
weeklyDelta: number;
|
||
/**
|
||
* First-half-mean vs second-half-mean delta of the raw (not trend)
|
||
* values. Reported as `null` for short windows or when one of the
|
||
* halves is empty (n < 2 split into two halves).
|
||
*/
|
||
splitHalfDelta: number | null;
|
||
}
|
||
|
||
/** Default threshold above which the split-half delta is computed. */
|
||
export const SPLIT_HALF_THRESHOLD_DAYS = 90;
|
||
|
||
export function computeWindowTrend(
|
||
input: WindowTrendInput,
|
||
splitHalfThresholdDays = SPLIT_HALF_THRESHOLD_DAYS,
|
||
): WindowTrendResult | null {
|
||
if (input.rawValues.length < 2) return null;
|
||
if (input.trendValues.length !== input.rawValues.length) return null;
|
||
if (!Number.isFinite(input.windowDays) || input.windowDays <= 0) return null;
|
||
|
||
const firstTrend = input.trendValues[0];
|
||
const lastTrend = input.trendValues[input.trendValues.length - 1];
|
||
const weeklyDelta = ((lastTrend - firstTrend) / input.windowDays) * 7;
|
||
|
||
let splitHalfDelta: number | null = null;
|
||
if (input.windowDays >= splitHalfThresholdDays) {
|
||
const mid = Math.floor(input.rawValues.length / 2);
|
||
const firstHalf = input.rawValues.slice(0, mid);
|
||
const secondHalf = input.rawValues.slice(mid);
|
||
if (firstHalf.length > 0 && secondHalf.length > 0) {
|
||
const meanFirst =
|
||
firstHalf.reduce((sum, value) => sum + value, 0) / firstHalf.length;
|
||
const meanSecond =
|
||
secondHalf.reduce((sum, value) => sum + value, 0) / secondHalf.length;
|
||
splitHalfDelta = meanSecond - meanFirst;
|
||
}
|
||
}
|
||
|
||
return { weeklyDelta, splitHalfDelta };
|
||
}
|