Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions lib/domain/reflection.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): DayEntry {
return { date, ...over }
}

function map(...entries: DayEntry[]): Record<string, DayEntry> {
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 })
})
})
156 changes: 156 additions & 0 deletions lib/domain/reflection.ts
Original file line number Diff line number Diff line change
@@ -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<string, DayEntry>,
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<string, DayEntry>,
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<string, DayEntry>,
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<string, DayEntry>,
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<string, DayEntry>,
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<string, DayEntry>,
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<string, DayEntry>,
todayKey: string = getTodayKey(),
days = 7
): Record<string, number> {
const counts: Record<string, number> = {}
for (const entry of entriesInWindow(entries, todayKey, days)) {
for (const id of entry.habitsCompleted ?? []) {
counts[id] = (counts[id] ?? 0) + 1
}
}
return counts
}
Loading