From ba9e3ee4d511e158e62f4d4ddeb8c856f6695478 Mon Sep 17 00:00:00 2001 From: Moskovkin Ilya Date: Mon, 8 Jun 2026 16:24:24 +0700 Subject: [PATCH] domain: add reflective derivations (the "mirror" half of Anchor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchor captures a rich daily signal — 2D mood (energy×valence), sleep, habits, intention, journal — but derives almost nothing back: selectors.ts only knows "ritual done?" and a streak count. That give-without-get is why the product feels truncated. This adds lib/domain/reflection.ts: pure, unit-tested derivations that turn the captured data into gentle, factual signals a calm UI can surface — reflections, not grades: - moodOf / moodShift within-day mood movement (morning → evening) - averageValence mood level over a window - moodDirection rising / steady / falling (trend, not snapshot) - activeDays consistency: shown up N of last M days - averageSleepHours sleep level over a window - consecutiveLowSleepNights "third short night in a row", streak-style - habitCounts per-habit completions over a window No UI yet — this is the provable foundation for the Home "Daily Anchor" card. 18 new tests, full suite 71 green, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/domain/reflection.test.ts | 198 ++++++++++++++++++++++++++++++++++ lib/domain/reflection.ts | 156 +++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 lib/domain/reflection.test.ts create mode 100644 lib/domain/reflection.ts diff --git a/lib/domain/reflection.test.ts b/lib/domain/reflection.test.ts new file mode 100644 index 0000000..b989a9d --- /dev/null +++ b/lib/domain/reflection.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest" +import { + moodOf, + entriesInWindow, + moodShift, + averageValence, + moodDirection, + activeDays, + averageSleepHours, + consecutiveLowSleepNights, + habitCounts, +} from "./reflection" +import type { DayEntry, MoodPoint } from "./entry" + +const today = "2026-05-20" + +function mood(valence: number, energy = 0.5): MoodPoint { + return { energy, valence } +} + +/** Build an entry for a given key with arbitrary overrides. */ +function entry(date: string, over: Partial = {}): DayEntry { + return { date, ...over } +} + +function map(...entries: DayEntry[]): Record { + return Object.fromEntries(entries.map((e) => [e.date, e])) +} + +describe("moodOf", () => { + it("prefers evening over morning, falls back to morning", () => { + expect(moodOf(undefined)).toBeUndefined() + expect(moodOf(entry("d"))).toBeUndefined() + expect(moodOf(entry("d", { morningMood: mood(0.3) }))).toEqual(mood(0.3)) + expect( + moodOf(entry("d", { morningMood: mood(0.3), eveningMood: mood(0.8) })) + ).toEqual(mood(0.8)) + }) +}) + +describe("entriesInWindow", () => { + it("returns present entries oldest → newest, inclusive of today", () => { + const entries = map( + entry("2026-05-20"), + entry("2026-05-18"), + // 2026-05-19 absent + entry("2026-05-12") // outside a 7-day window + ) + const got = entriesInWindow(entries, today, 7).map((e) => e.date) + expect(got).toEqual(["2026-05-18", "2026-05-20"]) + }) +}) + +describe("moodShift", () => { + it("is null unless both morning and evening moods exist", () => { + expect(moodShift(undefined)).toBeNull() + expect(moodShift(entry("d", { morningMood: mood(0.3) }))).toBeNull() + expect(moodShift(entry("d", { eveningMood: mood(0.3) }))).toBeNull() + }) + + it("is evening minus morning on both axes", () => { + const e = entry("d", { + morningMood: { energy: 0.4, valence: 0.3 }, + eveningMood: { energy: 0.6, valence: 0.7 }, + }) + const shift = moodShift(e)! + expect(shift.energy).toBeCloseTo(0.2) + expect(shift.valence).toBeCloseTo(0.4) + }) +}) + +describe("averageValence", () => { + it("is null when no moods in window", () => { + expect(averageValence({}, today)).toBeNull() + expect(averageValence(map(entry("2026-05-20")), today)).toBeNull() + }) + + it("averages valence using the representative mood per day", () => { + const entries = map( + entry("2026-05-20", { eveningMood: mood(0.8) }), + entry("2026-05-19", { morningMood: mood(0.4) }) + ) + expect(averageValence(entries, today)).toBeCloseTo(0.6) + }) +}) + +describe("moodDirection", () => { + it("is null until both halves of the window have a reading", () => { + const recentOnly = map( + entry("2026-05-20", { eveningMood: mood(0.8) }), + entry("2026-05-19", { eveningMood: mood(0.8) }) + ) + expect(moodDirection(recentOnly, today)).toBeNull() + }) + + it("reports rising when the recent half is happier than the older half", () => { + const entries = map( + entry("2026-05-20", { eveningMood: mood(0.8) }), + entry("2026-05-19", { eveningMood: mood(0.8) }), + entry("2026-05-18", { eveningMood: mood(0.7) }), + entry("2026-05-17", { eveningMood: mood(0.2) }), + entry("2026-05-16", { eveningMood: mood(0.2) }) + ) + expect(moodDirection(entries, today)).toBe("rising") + }) + + it("reports falling when the recent half is sadder", () => { + const entries = map( + entry("2026-05-20", { eveningMood: mood(0.2) }), + entry("2026-05-19", { eveningMood: mood(0.2) }), + entry("2026-05-17", { eveningMood: mood(0.8) }), + entry("2026-05-16", { eveningMood: mood(0.8) }) + ) + expect(moodDirection(entries, today)).toBe("falling") + }) + + it("reports steady when the halves are within epsilon", () => { + const entries = map( + entry("2026-05-20", { eveningMood: mood(0.5) }), + entry("2026-05-19", { eveningMood: mood(0.5) }), + entry("2026-05-17", { eveningMood: mood(0.52) }), + entry("2026-05-16", { eveningMood: mood(0.5) }) + ) + expect(moodDirection(entries, today)).toBe("steady") + }) +}) + +describe("activeDays", () => { + it("counts days with a completed ritual in the window", () => { + const entries = map( + entry("2026-05-20", { morningMood: mood(0.5), intention: "Be kind" }), + entry("2026-05-19", { eveningMood: mood(0.5), journal: "Long day" }), + entry("2026-05-18", { sleepHours: 7 }) // present but not active + ) + expect(activeDays(entries, today, 7)).toEqual({ count: 2, of: 7 }) + }) +}) + +describe("averageSleepHours", () => { + it("is null with no recorded sleep", () => { + expect(averageSleepHours({}, today)).toBeNull() + }) + + it("averages only recorded nights", () => { + const entries = map( + entry("2026-05-20", { sleepHours: 6 }), + entry("2026-05-19", { sleepHours: 8 }), + entry("2026-05-18", {}) // no sleep logged + ) + expect(averageSleepHours(entries, today)).toBeCloseTo(7) + }) +}) + +describe("consecutiveLowSleepNights", () => { + it("counts the run of recent sub-threshold nights", () => { + const entries = map( + entry("2026-05-20", { sleepHours: 5 }), + entry("2026-05-19", { sleepHours: 5.5 }), + entry("2026-05-18", { sleepHours: 7 }) // breaks the run + ) + expect(consecutiveLowSleepNights(entries, today, 6)).toBe(2) + }) + + it("does not break when tonight is not logged yet", () => { + const entries = map( + entry("2026-05-20", {}), // today, no sleep yet + entry("2026-05-19", { sleepHours: 5 }), + entry("2026-05-18", { sleepHours: 5 }), + entry("2026-05-17", { sleepHours: 8 }) + ) + expect(consecutiveLowSleepNights(entries, today, 6)).toBe(2) + }) + + it("is 0 when the most recent logged night is fine", () => { + const entries = map(entry("2026-05-20", { sleepHours: 8 })) + expect(consecutiveLowSleepNights(entries, today, 6)).toBe(0) + }) + + it("stops at a gap with no recorded value", () => { + const entries = map( + entry("2026-05-20", { sleepHours: 5 }), + entry("2026-05-19", {}), // gap + entry("2026-05-18", { sleepHours: 5 }) + ) + expect(consecutiveLowSleepNights(entries, today, 6)).toBe(1) + }) +}) + +describe("habitCounts", () => { + it("tallies completed habit ids across the window", () => { + const entries = map( + entry("2026-05-20", { habitsCompleted: ["move", "water"] }), + entry("2026-05-19", { habitsCompleted: ["move"] }), + entry("2026-05-18", { habitsCompleted: [] }) + ) + expect(habitCounts(entries, today)).toEqual({ move: 2, water: 1 }) + }) +}) diff --git a/lib/domain/reflection.ts b/lib/domain/reflection.ts new file mode 100644 index 0000000..8c6a597 --- /dev/null +++ b/lib/domain/reflection.ts @@ -0,0 +1,156 @@ +import type { DayEntry, MoodPoint } from "./entry" +import { getTodayKey, shiftKey } from "@/lib/time/today" + +/** + * Reflective derivations — the "mirror" half of Anchor. + * + * Where `selectors.ts` answers "is the ritual done?", this module answers + * "how have I been?". These are pure functions over the entry map so the + * meaning of every number is defined once and unit-tested, never re-derived + * inline in a component. + * + * Design intent: these are *reflections, not grades*. Nothing here ranks the + * user good/bad — they surface gentle, factual signals (mood drifted up, three + * short nights in a row, you showed up 5 of 7 days) that a calm UI can frame + * however it likes. + */ + +/** The most representative mood for a day: evening if logged, else morning. */ +export function moodOf(entry: DayEntry | undefined): MoodPoint | undefined { + return entry?.eveningMood ?? entry?.morningMood +} + +/** + * Entries falling inside the last `days` calendar days ending at `todayKey` + * (inclusive), oldest → newest. Days with no entry are simply absent. + */ +export function entriesInWindow( + entries: Record, + todayKey: string = getTodayKey(), + days = 7 +): DayEntry[] { + const out: DayEntry[] = [] + for (let i = days - 1; i >= 0; i--) { + const entry = entries[shiftKey(todayKey, -i)] + if (entry) out.push(entry) + } + return out +} + +/** + * How a single day's mood moved from morning to evening. Positive valence + * means the day lifted; positive energy means it built. `null` unless both + * the morning and evening mood were captured. + */ +export function moodShift(entry: DayEntry | undefined): MoodPoint | null { + if (!entry?.morningMood || !entry?.eveningMood) return null + return { + energy: entry.eveningMood.energy - entry.morningMood.energy, + valence: entry.eveningMood.valence - entry.morningMood.valence, + } +} + +/** Average valence (0–1) across days in the window that have any mood. */ +export function averageValence( + entries: Record, + todayKey: string = getTodayKey(), + days = 7 +): number | null { + const moods = entriesInWindow(entries, todayKey, days) + .map(moodOf) + .filter((m): m is MoodPoint => m !== undefined) + if (moods.length === 0) return null + return moods.reduce((sum, m) => sum + m.valence, 0) / moods.length +} + +export type MoodDirection = "rising" | "steady" | "falling" + +/** + * Trend, not snapshot: compares the recent half of the window against the + * older half. `null` until both halves carry at least one mood reading. + */ +export function moodDirection( + entries: Record, + todayKey: string = getTodayKey(), + days = 7, + epsilon = 0.05 +): MoodDirection | null { + const half = Math.floor(days / 2) + if (half < 1) return null + const older = averageValence(entries, shiftKey(todayKey, -half), days - half) + const recent = averageValence(entries, todayKey, half) + if (older === null || recent === null) return null + const delta = recent - older + if (delta > epsilon) return "rising" + if (delta < -epsilon) return "falling" + return "steady" +} + +/** How many of the last `days` were active (either ritual done), and of how many. */ +export function activeDays( + entries: Record, + todayKey: string = getTodayKey(), + days = 7 +): { count: number; of: number } { + let count = 0 + for (let i = 0; i < days; i++) { + const entry = entries[shiftKey(todayKey, -i)] + if (entry?.eveningMood && entry?.journal) count++ + else if (entry?.morningMood && entry?.intention) count++ + } + return { count, of: days } +} + +/** Average recorded sleep hours across the window. `null` if none recorded. */ +export function averageSleepHours( + entries: Record, + todayKey: string = getTodayKey(), + days = 7 +): number | null { + const hours = entriesInWindow(entries, todayKey, days) + .map((e) => e.sleepHours) + .filter((h): h is number => typeof h === "number") + if (hours.length === 0) return null + return hours.reduce((sum, h) => sum + h, 0) / hours.length +} + +/** + * Consecutive recorded nights, ending at the most recent logged night, that + * fell below `threshold` hours. A night not yet logged (e.g. today) does not + * break the run — we start from the latest night that has a value, mirroring + * how `computeStreak` treats an unfinished today. A logged night at/above the + * threshold, or a gap with no value, ends the run. + */ +export function consecutiveLowSleepNights( + entries: Record, + todayKey: string = getTodayKey(), + threshold = 6 +): number { + let cursor = + typeof entries[todayKey]?.sleepHours === "number" + ? todayKey + : shiftKey(todayKey, -1) + let count = 0 + while (true) { + const hours = entries[cursor]?.sleepHours + if (typeof hours !== "number" || hours >= threshold) break + count++ + cursor = shiftKey(cursor, -1) + } + return count +} + +/** Tally of how many times each habit id was completed across the window. */ +export function habitCounts( + entries: Record, + todayKey: string = getTodayKey(), + days = 7 +): Record { + const counts: Record = {} + for (const entry of entriesInWindow(entries, todayKey, days)) { + for (const id of entry.habitsCompleted ?? []) { + counts[id] = (counts[id] ?? 0) + 1 + } + } + return counts +}