mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(insights): add time-of-day and stability mood analyses
Extend the mood-aggregate engine with two pure helpers over the rows it already fetches: - computeTimeOfDayAverages buckets every entry into morning / afternoon / evening / night in the entry's own timezone (legacy null-tz rows fall back to UTC). A spread guard keeps the once-a-day logger — whose entries cluster in a single bucket — from producing a misleading daypart claim: the pattern is only marked reliable once at least two buckets each clear the per-bucket sample floor. - computeMoodStability maps the population standard deviation of the daily means onto a 0-100 score (higher = steadier) with a descriptive, non-judgemental band. Sparse loggers below the minimum-days floor get null rather than a meaningless number. Both feed the MoodAggregates object that drives the visible mood page and the LLM snapshot, so the prose and the charts agree. A new time-of-day narrative takeaway joins the ranked feed, gated on the same reliability flag plus the existing effect-magnitude bar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 685b53c0fd994324c270df2b9a5ac964bb442c82)
This commit is contained in:
1 parent
3694efe6e8
commit
141c083ff6
5 files changed
+525
-1
No files matched your search
@@ -1,13 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
STABILITY_MIN_DAYS,
|
||||
STABILITY_SD_FULL_SCALE,
|
||||
TIME_OF_DAY_MIN_BUCKET_SAMPLES,
|
||||
bucketForHour,
|
||||
computeDistribution,
|
||||
computeHeatmapCells,
|
||||
computeInTargetPct,
|
||||
computeMoodAggregates,
|
||||
computeMoodMetricCorrelation,
|
||||
computeMoodStability,
|
||||
computeStructuredTagSummary,
|
||||
computeTagSummary,
|
||||
computeTimeOfDayAverages,
|
||||
computeWeekdayAverages,
|
||||
selectHeatmapWindow,
|
||||
type CrossMetricMeasurement,
|
||||
@@ -300,4 +306,159 @@ describe("computeMoodAggregates", () => {
|
||||
expect(agg.heatmap.cells).toEqual([]);
|
||||
expect(agg.distribution.every((d) => d.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the time-of-day pattern and stability fields", () => {
|
||||
const agg = computeMoodAggregates({
|
||||
entries: [],
|
||||
measurements: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(agg.timeOfDay.reliable).toBe(false);
|
||||
expect(agg.timeOfDay.buckets.map((b) => b.bucket)).toEqual([
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
"night",
|
||||
]);
|
||||
expect(agg.stability).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketForHour", () => {
|
||||
it("maps each hour to its part of day with night wrapping midnight", () => {
|
||||
expect(bucketForHour(0)).toBe("night");
|
||||
expect(bucketForHour(4)).toBe("night");
|
||||
expect(bucketForHour(5)).toBe("morning");
|
||||
expect(bucketForHour(11)).toBe("morning");
|
||||
expect(bucketForHour(12)).toBe("afternoon");
|
||||
expect(bucketForHour(16)).toBe("afternoon");
|
||||
expect(bucketForHour(17)).toBe("evening");
|
||||
expect(bucketForHour(20)).toBe("evening");
|
||||
expect(bucketForHour(21)).toBe("night");
|
||||
expect(bucketForHour(23)).toBe("night");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTimeOfDayAverages", () => {
|
||||
/** Build an entry at a fixed UTC instant + hour, in a given tz. */
|
||||
function tzEntry(
|
||||
daysAgo: number,
|
||||
utcHour: number,
|
||||
score: number,
|
||||
tz: string | null = "UTC",
|
||||
): MoodAggregateEntry {
|
||||
const base = new Date(NOW.getTime() - daysAgo * dayMs);
|
||||
base.setUTCHours(utcHour, 0, 0, 0);
|
||||
return { date: dayKey(daysAgo), score, tags: null, moodLoggedAt: base, tz };
|
||||
}
|
||||
|
||||
it("buckets entries by part of day and averages per bucket", () => {
|
||||
const entries: MoodAggregateEntry[] = [
|
||||
tzEntry(1, 8, 5),
|
||||
tzEntry(2, 9, 3),
|
||||
tzEntry(3, 14, 4),
|
||||
tzEntry(4, 14, 2),
|
||||
];
|
||||
const pattern = computeTimeOfDayAverages(entries);
|
||||
const morning = pattern.buckets.find((b) => b.bucket === "morning");
|
||||
const afternoon = pattern.buckets.find((b) => b.bucket === "afternoon");
|
||||
expect(morning).toEqual({ bucket: "morning", avgScore: 4, count: 2 });
|
||||
expect(afternoon).toEqual({ bucket: "afternoon", avgScore: 3, count: 2 });
|
||||
});
|
||||
|
||||
it("honours the per-row timezone when bucketing the local hour", () => {
|
||||
// 06:00 UTC is 08:00 in Berlin (summer, UTC+2) → morning, but stays
|
||||
// in the night bucket (00:00–04:59) under a UTC-6 zone.
|
||||
const berlin = computeTimeOfDayAverages([
|
||||
tzEntry(1, 6, 5, "Europe/Berlin"),
|
||||
]);
|
||||
expect(berlin.buckets.find((b) => b.bucket === "morning")?.count).toBe(1);
|
||||
|
||||
const chicago = computeTimeOfDayAverages([
|
||||
tzEntry(1, 6, 5, "America/Chicago"),
|
||||
]);
|
||||
expect(chicago.buckets.find((b) => b.bucket === "night")?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to UTC for legacy rows without a tz", () => {
|
||||
const pattern = computeTimeOfDayAverages([tzEntry(1, 14, 5, null)]);
|
||||
expect(pattern.buckets.find((b) => b.bucket === "afternoon")?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("is unreliable for a once-a-day logger clustered in one bucket", () => {
|
||||
// Ten nightly logs, all in the evening bucket → single-bucket spread.
|
||||
const entries = Array.from({ length: 10 }, (_, i) => tzEntry(i, 19, 4));
|
||||
const pattern = computeTimeOfDayAverages(entries);
|
||||
expect(pattern.reliable).toBe(false);
|
||||
expect(pattern.best).toBeNull();
|
||||
expect(pattern.worst).toBeNull();
|
||||
});
|
||||
|
||||
it("is unreliable when a second bucket is below the sample floor", () => {
|
||||
const entries = [
|
||||
...Array.from({ length: 5 }, (_, i) => tzEntry(i, 8, 5)), // morning ≥ floor
|
||||
tzEntry(5, 14, 3), // a single afternoon log, below the floor
|
||||
];
|
||||
expect(TIME_OF_DAY_MIN_BUCKET_SAMPLES).toBeGreaterThan(1);
|
||||
const pattern = computeTimeOfDayAverages(entries);
|
||||
expect(pattern.reliable).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces best/worst once two buckets clear the sample floor", () => {
|
||||
const entries = [
|
||||
...Array.from({ length: 4 }, (_, i) => tzEntry(i, 8, 5)), // morning avg 5
|
||||
...Array.from({ length: 4 }, (_, i) => tzEntry(i + 4, 22, 2)), // night avg 2
|
||||
];
|
||||
const pattern = computeTimeOfDayAverages(entries);
|
||||
expect(pattern.reliable).toBe(true);
|
||||
expect(pattern.best).toBe("morning");
|
||||
expect(pattern.worst).toBe("night");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeMoodStability", () => {
|
||||
function points(values: number[]) {
|
||||
return values.map((value, i) => ({ dayOffset: i, value }));
|
||||
}
|
||||
|
||||
it("returns null below the minimum-days floor", () => {
|
||||
const sparse = points(Array(STABILITY_MIN_DAYS - 1).fill(3));
|
||||
expect(computeMoodStability(sparse)).toBeNull();
|
||||
});
|
||||
|
||||
it("scores a perfectly flat mood at 100 (very steady)", () => {
|
||||
const flat = points(Array(STABILITY_MIN_DAYS).fill(3));
|
||||
const stability = computeMoodStability(flat);
|
||||
expect(stability).not.toBeNull();
|
||||
expect(stability?.score).toBe(100);
|
||||
expect(stability?.stdDev).toBe(0);
|
||||
expect(stability?.band).toBe("verySteady");
|
||||
expect(stability?.days).toBe(STABILITY_MIN_DAYS);
|
||||
});
|
||||
|
||||
it("scores an sd at or beyond the full-scale cap at 0 (very variable)", () => {
|
||||
// Alternating 1 / 5 → sd 2.0 > FULL_SCALE → clamped to 0.
|
||||
const swing = points(
|
||||
Array.from({ length: 10 }, (_, i) => (i % 2 === 0 ? 1 : 5)),
|
||||
);
|
||||
const stability = computeMoodStability(swing);
|
||||
expect(stability?.score).toBe(0);
|
||||
expect(stability?.band).toBe("veryVariable");
|
||||
expect(stability?.stdDev).toBeGreaterThanOrEqual(STABILITY_SD_FULL_SCALE);
|
||||
});
|
||||
|
||||
it("scales linearly between the bounds", () => {
|
||||
// A balanced series alternating ±halfFull around its mean has a
|
||||
// population sd of exactly halfFull → score 50.
|
||||
const halfFull = STABILITY_SD_FULL_SCALE / 2;
|
||||
const balanced = points(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
i % 2 === 0 ? 3 - halfFull : 3 + halfFull,
|
||||
),
|
||||
);
|
||||
const stability = computeMoodStability(balanced);
|
||||
expect(stability?.stdDev).toBe(halfFull);
|
||||
expect(stability?.score).toBe(50);
|
||||
expect(stability?.band).toBe("variable");
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ function emptyInput(): MoodNarrativeInput {
|
||||
return {
|
||||
daily: [],
|
||||
weekday: [],
|
||||
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
|
||||
tags: [],
|
||||
structuredTags: [],
|
||||
inTargetPct: null,
|
||||
@@ -355,6 +356,7 @@ describe("computeMoodNarratives — ranking and capping", () => {
|
||||
const input: MoodNarrativeInput = {
|
||||
daily: [...recent, ...prior],
|
||||
weekday,
|
||||
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
|
||||
tags: [
|
||||
{ tag: "great", count: 10, avgScore: 5 },
|
||||
{ tag: "bad", count: 10, avgScore: 1 },
|
||||
@@ -376,4 +378,69 @@ describe("computeMoodNarratives — ranking and capping", () => {
|
||||
it("exposes a stable minimum-days constant", () => {
|
||||
expect(MOOD_NARRATIVE_MIN_DAYS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stays silent on time-of-day when the pattern is unreliable", () => {
|
||||
const input: MoodNarrativeInput = {
|
||||
...emptyInput(),
|
||||
timeOfDay: {
|
||||
// Spread is fine but the effect is computed from best/worst below.
|
||||
buckets: [
|
||||
{ bucket: "morning", avgScore: 4, count: 5 },
|
||||
{ bucket: "afternoon", avgScore: 4, count: 5 },
|
||||
{ bucket: "evening", avgScore: null, count: 0 },
|
||||
{ bucket: "night", avgScore: null, count: 0 },
|
||||
],
|
||||
reliable: false,
|
||||
best: null,
|
||||
worst: null,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
computeMoodNarratives(input).find((n) => n.kind === "time-of-day"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fires the time-of-day takeaway when reliable and the effect clears the bar", () => {
|
||||
const input: MoodNarrativeInput = {
|
||||
...emptyInput(),
|
||||
timeOfDay: {
|
||||
buckets: [
|
||||
{ bucket: "morning", avgScore: 4.5, count: 6 },
|
||||
{ bucket: "afternoon", avgScore: 3.0, count: 6 },
|
||||
{ bucket: "evening", avgScore: null, count: 0 },
|
||||
{ bucket: "night", avgScore: null, count: 0 },
|
||||
],
|
||||
reliable: true,
|
||||
best: "morning",
|
||||
worst: "afternoon",
|
||||
},
|
||||
};
|
||||
const item = computeMoodNarratives(input).find(
|
||||
(n) => n.kind === "time-of-day",
|
||||
);
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.messageKey).toBe("insights.mood.narrative.timeOfDay");
|
||||
expect(item?.vars.bucketKey).toBe("insights.mood.timeOfDay.morning");
|
||||
expect(item?.vars.value).toBe("4.5");
|
||||
});
|
||||
|
||||
it("stays silent on time-of-day when best/worst are within the effect threshold", () => {
|
||||
const input: MoodNarrativeInput = {
|
||||
...emptyInput(),
|
||||
timeOfDay: {
|
||||
buckets: [
|
||||
{ bucket: "morning", avgScore: 3.6, count: 6 },
|
||||
{ bucket: "afternoon", avgScore: 3.5, count: 6 },
|
||||
{ bucket: "evening", avgScore: null, count: 0 },
|
||||
{ bucket: "night", avgScore: null, count: 0 },
|
||||
],
|
||||
reliable: true,
|
||||
best: "morning",
|
||||
worst: "afternoon",
|
||||
},
|
||||
};
|
||||
expect(
|
||||
computeMoodNarratives(input).find((n) => n.kind === "time-of-day"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import { round, summarizeSeries } from "@/lib/insights/status-shared";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
import {
|
||||
computeMoodNarratives,
|
||||
type MoodNarrative,
|
||||
@@ -85,6 +86,14 @@ export interface MoodAggregateEntry {
|
||||
tags: unknown;
|
||||
/** Exact log timestamp — carries time-of-day. */
|
||||
moodLoggedAt: Date;
|
||||
/**
|
||||
* v1.9.0 — per-row IANA timezone the entry was logged under. Used to
|
||||
* bucket `moodLoggedAt` into a part of day in the user's own local
|
||||
* time. Optional: legacy rows (`tz IS NULL`) fall back to UTC, mirroring
|
||||
* the rollup-tier convention; the legacy aggregate tests keep their
|
||||
* narrow fixtures.
|
||||
*/
|
||||
tz?: string | null;
|
||||
/**
|
||||
* v1.8.5 — structured tags from the taxonomy join. Additive next to
|
||||
* the flat `tags`: an entry can carry both. Optional so the legacy
|
||||
@@ -342,6 +351,202 @@ export function computeWeekdayAverages(
|
||||
return rows;
|
||||
}
|
||||
|
||||
// --- Time-of-day pattern (tz-aware part-of-day buckets) ---
|
||||
|
||||
/** Part-of-day bucket key. Order is morning → night for stable rendering. */
|
||||
export type TimeOfDayBucket = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
/** Ordered bucket list — drives both the chart x-axis and the iteration. */
|
||||
export const TIME_OF_DAY_BUCKETS: readonly TimeOfDayBucket[] = [
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
"night",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Minimum entries a single bucket must hold before it counts toward the
|
||||
* pattern. A lone log in a bucket is noise, not a daypart preference.
|
||||
*/
|
||||
export const TIME_OF_DAY_MIN_BUCKET_SAMPLES = 3;
|
||||
|
||||
/**
|
||||
* Minimum distinct populated buckets (each at or above the per-bucket
|
||||
* sample floor) before the pattern surfaces at all. The guard against the
|
||||
* once-a-day logger: a nightly Telegram check-in clusters in a single
|
||||
* bucket, which can never clear a two-bucket spread, so the "you feel
|
||||
* best in the morning" takeaway never fires misleadingly.
|
||||
*/
|
||||
export const TIME_OF_DAY_MIN_SPREAD = 2;
|
||||
|
||||
/**
|
||||
* Map a local hour-of-day (0..23) to its part-of-day bucket.
|
||||
*
|
||||
* morning 05:00–11:59, afternoon 12:00–16:59, evening 17:00–20:59,
|
||||
* night 21:00–04:59. The boundaries follow the common Daylio / consumer
|
||||
* convention; night wraps midnight so a 02:00 log lands in `night`.
|
||||
*/
|
||||
export function bucketForHour(hour: number): TimeOfDayBucket {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 17) return "afternoon";
|
||||
if (hour >= 17 && hour < 21) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
export interface TimeOfDayRow {
|
||||
bucket: TimeOfDayBucket;
|
||||
avgScore: number | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TimeOfDayPattern {
|
||||
/** All four buckets, in canonical order; unpopulated buckets carry null. */
|
||||
buckets: TimeOfDayRow[];
|
||||
/**
|
||||
* Whether the spread + sample floors are cleared — i.e. the pattern is
|
||||
* trustworthy enough to surface a chart and a takeaway. False for the
|
||||
* once-a-day logger (everything in one bucket) or a sparse history.
|
||||
*/
|
||||
reliable: boolean;
|
||||
/** Best / worst bucket keys when `reliable`; null otherwise. */
|
||||
best: TimeOfDayBucket | null;
|
||||
worst: TimeOfDayBucket | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Average mood per part of day, bucketed in each entry's own timezone.
|
||||
*
|
||||
* `moodLoggedAt` carries the exact instant; `tz` (per-row IANA) anchors
|
||||
* the local hour. Legacy rows without a `tz` fall back to UTC — the same
|
||||
* convention the rollup tier uses for `tz IS NULL` mood rows. Every entry
|
||||
* (not the daily mean) feeds its bucket, because the question is "what
|
||||
* time of day do I feel best", which a daily collapse would erase.
|
||||
*
|
||||
* The `reliable` flag is the once-a-day-logger guard: it only trips when
|
||||
* at least `TIME_OF_DAY_MIN_SPREAD` buckets each carry
|
||||
* `TIME_OF_DAY_MIN_BUCKET_SAMPLES` entries. `best`/`worst` are computed
|
||||
* over the populated-and-sufficient buckets only.
|
||||
*/
|
||||
export function computeTimeOfDayAverages(
|
||||
entries: MoodAggregateEntry[],
|
||||
): TimeOfDayPattern {
|
||||
const sums = new Map<TimeOfDayBucket, { sum: number; count: number }>();
|
||||
for (const entry of entries) {
|
||||
const tz = entry.tz ?? "UTC";
|
||||
const { hour } = getLocalDateParts(entry.moodLoggedAt, tz);
|
||||
const bucket = bucketForHour(hour);
|
||||
const cur = sums.get(bucket) ?? { sum: 0, count: 0 };
|
||||
cur.sum += entry.score;
|
||||
cur.count += 1;
|
||||
sums.set(bucket, cur);
|
||||
}
|
||||
|
||||
const buckets: TimeOfDayRow[] = TIME_OF_DAY_BUCKETS.map((bucket) => {
|
||||
const agg = sums.get(bucket);
|
||||
return {
|
||||
bucket,
|
||||
avgScore: agg ? round(agg.sum / agg.count, 2) : null,
|
||||
count: agg?.count ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const sufficient = buckets.filter(
|
||||
(row): row is TimeOfDayRow & { avgScore: number } =>
|
||||
row.avgScore != null && row.count >= TIME_OF_DAY_MIN_BUCKET_SAMPLES,
|
||||
);
|
||||
const reliable = sufficient.length >= TIME_OF_DAY_MIN_SPREAD;
|
||||
|
||||
let best: TimeOfDayBucket | null = null;
|
||||
let worst: TimeOfDayBucket | null = null;
|
||||
if (reliable) {
|
||||
let bestRow = sufficient[0];
|
||||
let worstRow = sufficient[0];
|
||||
for (const row of sufficient) {
|
||||
if (row.avgScore > bestRow.avgScore) bestRow = row;
|
||||
if (row.avgScore < worstRow.avgScore) worstRow = row;
|
||||
}
|
||||
best = bestRow.bucket;
|
||||
worst = worstRow.bucket;
|
||||
}
|
||||
|
||||
return { buckets, reliable, best, worst };
|
||||
}
|
||||
|
||||
// --- Mood stability score (variance of daily means → 0..100) ---
|
||||
|
||||
/**
|
||||
* Minimum distinct daily points before a stability score is computed. A
|
||||
* handful of days has no meaningful variance signal, so a sparse logger
|
||||
* gets `null` (no tile, no sentence) rather than a noisy number.
|
||||
*/
|
||||
export const STABILITY_MIN_DAYS = 7;
|
||||
|
||||
/**
|
||||
* The full-scale standard deviation that maps to a 0 stability score. The
|
||||
* mood scale spans 1..5, so the widest day-to-day swing is 4 points; a
|
||||
* population SD at or above this is treated as maximally unstable. Below
|
||||
* it, the score scales linearly toward 100 (perfectly steady).
|
||||
*/
|
||||
export const STABILITY_SD_FULL_SCALE = 1.5;
|
||||
|
||||
export type StabilityBand = "verySteady" | "steady" | "variable" | "veryVariable";
|
||||
|
||||
export interface MoodStability {
|
||||
/** 0..100; higher = steadier (lower day-to-day variance). */
|
||||
score: number;
|
||||
/** Population standard deviation of the daily means (raw, for tests). */
|
||||
stdDev: number;
|
||||
/** Descriptive, non-judgemental band. */
|
||||
band: StabilityBand;
|
||||
/** Daily points the score was computed over. */
|
||||
days: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a 0..100 stability score to a four-band descriptive label.
|
||||
* Descriptive, not judgemental — some variation is healthy (Oura framing),
|
||||
* so the bands read "steady" / "variable", never "good" / "bad".
|
||||
*/
|
||||
function stabilityBand(score: number): StabilityBand {
|
||||
if (score >= 80) return "verySteady";
|
||||
if (score >= 60) return "steady";
|
||||
if (score >= 40) return "variable";
|
||||
return "veryVariable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood-stability score from the population standard deviation of the
|
||||
* daily means.
|
||||
*
|
||||
* Formula:
|
||||
* sd = sqrt( mean( (x_i - mean(x))^2 ) ) // population SD
|
||||
* score = round( 100 * (1 - min(sd, FULL) / FULL) ) // clamped 0..100
|
||||
*
|
||||
* A flat mood (sd = 0) scores 100; an sd at or beyond
|
||||
* `STABILITY_SD_FULL_SCALE` scores 0. Returns `null` below
|
||||
* `STABILITY_MIN_DAYS` distinct daily points so a sparse logger never
|
||||
* gets a meaningless score.
|
||||
*/
|
||||
export function computeMoodStability(daily: DailyPoint[]): MoodStability | null {
|
||||
if (daily.length < STABILITY_MIN_DAYS) return null;
|
||||
|
||||
const values = daily.map((bucket) => bucket.value);
|
||||
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
const normalised = Math.min(stdDev, STABILITY_SD_FULL_SCALE) / STABILITY_SD_FULL_SCALE;
|
||||
const score = Math.round(100 * (1 - normalised));
|
||||
|
||||
return {
|
||||
score,
|
||||
stdDev: round(stdDev, 3),
|
||||
band: stabilityBand(score),
|
||||
days: daily.length,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Heatmap (one cell per day, score band per cell) ---
|
||||
|
||||
export type HeatmapWindowDays = 30 | 90 | 365;
|
||||
@@ -472,6 +677,17 @@ export interface MoodAggregates {
|
||||
};
|
||||
distribution: DistributionRow[];
|
||||
weekday: WeekdayRow[];
|
||||
/**
|
||||
* v1.9.0 — average mood per part of day, bucketed in each entry's own
|
||||
* timezone. `reliable` gates the chart + takeaway against the once-a-day
|
||||
* logger (everything in one bucket).
|
||||
*/
|
||||
timeOfDay: TimeOfDayPattern;
|
||||
/**
|
||||
* v1.9.0 — day-to-day stability score (0..100, higher = steadier) plus a
|
||||
* descriptive band. `null` for a sparse logger (< STABILITY_MIN_DAYS).
|
||||
*/
|
||||
stability: MoodStability | null;
|
||||
tags: TagSummaryRow[];
|
||||
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
|
||||
structuredTags: StructuredTagRow[];
|
||||
@@ -533,6 +749,8 @@ export function computeMoodAggregates(args: {
|
||||
const windowDays = selectHeatmapWindow(totalSpanDays);
|
||||
|
||||
const weekday = computeWeekdayAverages(moodDaily, now);
|
||||
const timeOfDay = computeTimeOfDayAverages(entries);
|
||||
const stability = computeMoodStability(moodDaily);
|
||||
const tags = computeTagSummary(entries, now);
|
||||
const structuredTags = computeStructuredTagSummary(entries, now);
|
||||
const inTargetPct = computeInTargetPct(moodDaily);
|
||||
@@ -560,11 +778,14 @@ export function computeMoodAggregates(args: {
|
||||
},
|
||||
distribution: computeDistribution(moodDaily),
|
||||
weekday,
|
||||
timeOfDay,
|
||||
stability,
|
||||
tags,
|
||||
structuredTags,
|
||||
narratives: computeMoodNarratives({
|
||||
daily: moodDaily,
|
||||
weekday,
|
||||
timeOfDay,
|
||||
tags,
|
||||
structuredTags,
|
||||
inTargetPct,
|
||||
@@ -604,6 +825,9 @@ export async function fetchMoodAggregates(
|
||||
score: true,
|
||||
tags: true,
|
||||
moodLoggedAt: true,
|
||||
// v1.9.0 — per-row IANA tz anchors the part-of-day bucketing in
|
||||
// the user's local time (legacy null rows fall back to UTC).
|
||||
tz: true,
|
||||
// v1.8.5 — pull the structured-tag links + their catalog rows so
|
||||
// the breakdown can fold structured tags next to the flat ones.
|
||||
tagLinks: {
|
||||
@@ -626,6 +850,7 @@ export async function fetchMoodAggregates(
|
||||
score: row.score,
|
||||
tags: parseTags(row.tags),
|
||||
moodLoggedAt: row.moodLoggedAt,
|
||||
tz: row.tz,
|
||||
structuredTags: row.tagLinks.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { round } from "@/lib/insights/status-shared";
|
||||
import type {
|
||||
StructuredTagRow,
|
||||
TagSummaryRow,
|
||||
TimeOfDayPattern,
|
||||
WeekdayRow,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
|
||||
@@ -49,7 +50,8 @@ export type MoodNarrativeKind =
|
||||
| "tag-lift"
|
||||
| "tag-drop"
|
||||
| "in-target"
|
||||
| "streak";
|
||||
| "streak"
|
||||
| "time-of-day";
|
||||
|
||||
/**
|
||||
* A single takeaway. `messageKey` resolves to an i18n template; `vars`
|
||||
@@ -70,6 +72,8 @@ type DailyPoint = { dayOffset: number; value: number };
|
||||
export interface MoodNarrativeInput {
|
||||
daily: DailyPoint[];
|
||||
weekday: WeekdayRow[];
|
||||
/** v1.9.0 — tz-aware part-of-day pattern (gated on its own spread floor). */
|
||||
timeOfDay: TimeOfDayPattern;
|
||||
tags: TagSummaryRow[];
|
||||
structuredTags: StructuredTagRow[];
|
||||
inTargetPct: number | null;
|
||||
@@ -78,6 +82,14 @@ export interface MoodNarrativeInput {
|
||||
now: Date;
|
||||
}
|
||||
|
||||
/** i18n label keys for each part-of-day bucket. */
|
||||
const TIME_OF_DAY_LABEL_KEYS = {
|
||||
morning: "insights.mood.timeOfDay.morning",
|
||||
afternoon: "insights.mood.timeOfDay.afternoon",
|
||||
evening: "insights.mood.timeOfDay.evening",
|
||||
night: "insights.mood.timeOfDay.night",
|
||||
} as const;
|
||||
|
||||
const WEEKDAY_LABEL_KEYS = [
|
||||
"charts.weekdaysFull.mon",
|
||||
"charts.weekdaysFull.tue",
|
||||
@@ -364,6 +376,38 @@ function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-of-day takeaway: the part of day the user feels best.
|
||||
*
|
||||
* Rides the `TimeOfDayPattern.reliable` flag — already the once-a-day-
|
||||
* logger guard (≥2 buckets with ≥3 entries each) — so this builder adds
|
||||
* only the effect-magnitude gate on top: the best bucket must clear
|
||||
* `MOOD_NARRATIVE_MIN_EFFECT` over the worst before it counts as a
|
||||
* pattern rather than noise.
|
||||
*/
|
||||
function timeOfDayNarrative(input: MoodNarrativeInput): MoodNarrative | null {
|
||||
const { reliable, best, worst, buckets } = input.timeOfDay;
|
||||
if (!reliable || best == null || worst == null) return null;
|
||||
|
||||
const byBucket = new Map(buckets.map((row) => [row.bucket, row]));
|
||||
const bestRow = byBucket.get(best);
|
||||
const worstRow = byBucket.get(worst);
|
||||
if (bestRow?.avgScore == null || worstRow?.avgScore == null) return null;
|
||||
|
||||
const delta = round(bestRow.avgScore - worstRow.avgScore, 2);
|
||||
if (delta < MOOD_NARRATIVE_MIN_EFFECT) return null;
|
||||
|
||||
return {
|
||||
kind: "time-of-day",
|
||||
messageKey: "insights.mood.narrative.timeOfDay",
|
||||
vars: {
|
||||
bucketKey: TIME_OF_DAY_LABEL_KEYS[best],
|
||||
value: bestRow.avgScore.toFixed(1),
|
||||
},
|
||||
strength: delta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ranked, threshold-gated narrative feed. Pure over the
|
||||
* supplied aggregates; emits only takeaways whose named thresholds are
|
||||
@@ -383,6 +427,9 @@ export function computeMoodNarratives(
|
||||
const weekend = weekendNarrative(input);
|
||||
if (weekend) all.push(weekend);
|
||||
|
||||
const timeOfDay = timeOfDayNarrative(input);
|
||||
if (timeOfDay) all.push(timeOfDay);
|
||||
|
||||
const inTarget = inTargetNarrative(input);
|
||||
if (inTarget) all.push(inTarget);
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
MOOD_ORANGE_MAX,
|
||||
MOOD_ORANGE_MIN,
|
||||
computeInTargetPct,
|
||||
computeMoodStability,
|
||||
computeStructuredTagSummary,
|
||||
computeTagSummary,
|
||||
computeTimeOfDayAverages,
|
||||
computeWeekdayAverages,
|
||||
pairDailyBuckets,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
@@ -122,6 +124,9 @@ export async function generateMoodStatusForUser(
|
||||
score: true,
|
||||
tags: true,
|
||||
moodLoggedAt: true,
|
||||
// v1.9.0 — per-row tz anchors the part-of-day bucketing so the
|
||||
// snapshot's time-of-day signal matches the visible mood page.
|
||||
tz: true,
|
||||
// v1.8.6 — pull the structured-tag links so the snapshot feeds
|
||||
// `computeMoodNarratives` the same structured-tag pool the visible
|
||||
// mood page does (shown == sent for the tag→mood takeaways).
|
||||
@@ -145,6 +150,7 @@ export async function generateMoodStatusForUser(
|
||||
score: row.score,
|
||||
tags: row.tags,
|
||||
moodLoggedAt: row.moodLoggedAt,
|
||||
tz: row.tz,
|
||||
structuredTags: (row.tagLinks ?? []).map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
@@ -266,9 +272,16 @@ export async function generateMoodStatusForUser(
|
||||
// model writes never contradicts the takeaways on screen (shown ==
|
||||
// sent). Both the flat free-text tags and the structured taxonomy tags
|
||||
// feed the tag→mood pool, matching the visible feed exactly.
|
||||
// v1.9.0 — tz-aware part-of-day pattern + day-to-day stability. Computed
|
||||
// here so the snapshot carries the same signals the visible mood page
|
||||
// surfaces (shown == sent), and the narrative feed can cite the daypart.
|
||||
const timeOfDay = computeTimeOfDayAverages(entries);
|
||||
const stability = computeMoodStability(moodSeries.daily);
|
||||
|
||||
const narratives = computeMoodNarratives({
|
||||
daily: moodSeries.daily,
|
||||
weekday: computeWeekdayAverages(moodSeries.daily, now),
|
||||
timeOfDay,
|
||||
tags: tagSummary,
|
||||
structuredTags: structuredTagSummary,
|
||||
inTargetPct: inTargetPctLast30DailyPoints,
|
||||
@@ -307,6 +320,17 @@ export async function generateMoodStatusForUser(
|
||||
},
|
||||
tags: tagSummary.length > 0 ? tagSummary : null,
|
||||
narratives: narratives.length > 0 ? narratives : null,
|
||||
// v1.9.0 — only emit the daypart pattern when it cleared its
|
||||
// spread/sample floors, so the model never reasons over a once-a-day
|
||||
// logger's single-bucket artefact.
|
||||
timeOfDay: timeOfDay.reliable
|
||||
? {
|
||||
buckets: timeOfDay.buckets,
|
||||
best: timeOfDay.best,
|
||||
worst: timeOfDay.worst,
|
||||
}
|
||||
: null,
|
||||
stability,
|
||||
},
|
||||
crossMetricContext:
|
||||
weightSeries.daily.length >= 3 ||
|
||||
|
||||
Reference in new issue
Block a user