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:
Marc-André Bombeck committed 2026-06-02 16:45:14 +02:00
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();
});
});
+225
View File
@@ -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,
+48 -1
View File
@@ -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);
+24
View File
@@ -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 ||