From 141c083ff6f5bbc3c492bc12bbca4f00ef6fe828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:38:10 +0200 Subject: [PATCH] feat(insights): add time-of-day and stability mood analyses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) (cherry picked from commit 685b53c0fd994324c270df2b9a5ac964bb442c82) --- .../__tests__/mood-aggregates.test.ts | 161 +++++++++++++ .../__tests__/mood-narratives.test.ts | 67 ++++++ src/lib/insights/mood-aggregates.ts | 225 ++++++++++++++++++ src/lib/insights/mood-narratives.ts | 49 +++- src/lib/insights/mood-status.ts | 24 ++ 5 files changed, 525 insertions(+), 1 deletion(-) diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 6d8deb75f..4cd610841 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -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"); + }); }); diff --git a/src/lib/insights/__tests__/mood-narratives.test.ts b/src/lib/insights/__tests__/mood-narratives.test.ts index 35aa8dcfd..de8f27846 100644 --- a/src/lib/insights/__tests__/mood-narratives.test.ts +++ b/src/lib/insights/__tests__/mood-narratives.test.ts @@ -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(); + }); }); diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 3c02d75b8..a35c0aaf3 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -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(); + 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, diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 81078a01e..0a9039a22 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -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); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index a69bb81d1..1467ea547 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -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 ||