Files
famapp/src/modules/journal/server/analytics.ts
T
ginnoir 04ae809e07 feat(journal): add per-user mood journal module (task 86)
Ship journal entries with mood tracking, insights, and Recharts charts.

Adds v1 API endpoints per ADR 0005 and migration 0020_journal_entries.
2026-07-04 19:41:15 -05:00

154 lines
4.7 KiB
TypeScript

import { averageMoodScore, getMoodById, MOOD_CATALOG } from "../mood-catalog";
import { toDayKey } from "../day-key";
export type JournalEntryPoint = {
id: string;
recordedAt: string;
moods: string[];
stress: number | null;
pillsTaken: boolean | null;
};
export function entryMoodScore(entry: Pick<JournalEntryPoint, "moods">): number | null {
return averageMoodScore(entry.moods);
}
export function computeDayStreak(entryDays: string[]): number {
if (entryDays.length === 0) return 0;
const days = new Set(entryDays);
const todayKey = toDayKey(new Date());
let cursor = days.has(todayKey) ? new Date() : addDays(new Date(), -1);
let streak = 0;
while (days.has(toDayKey(cursor))) {
streak += 1;
cursor = addDays(cursor, -1);
}
return streak;
}
export function topMoodId(entries: Pick<JournalEntryPoint, "moods">[]): string | null {
const counts = new Map<string, number>();
for (const entry of entries) {
for (const moodId of entry.moods) {
counts.set(moodId, (counts.get(moodId) ?? 0) + 1);
}
}
let best: string | null = null;
let bestCount = 0;
for (const [id, count] of counts) {
if (count > bestCount) {
best = id;
bestCount = count;
}
}
return best;
}
export function averageStress(entries: Pick<JournalEntryPoint, "stress">[]): number | null {
const values = entries.map((e) => e.stress).filter((v): v is number => typeof v === "number");
if (values.length === 0) return null;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
export function pillsCorrelation(entries: JournalEntryPoint[]) {
const withPills = entries.filter((e) => e.pillsTaken === true);
const withoutPills = entries.filter((e) => e.pillsTaken === false);
const avgWith = averageEntryMoodScores(withPills);
const avgWithout = averageEntryMoodScores(withoutPills);
return {
withPillsCount: withPills.length,
withoutPillsCount: withoutPills.length,
avgMoodWithPills: avgWith,
avgMoodWithoutPills: avgWithout,
};
}
export function stressMoodCorrelation(entries: JournalEntryPoint[]) {
const points = entries
.map((entry) => {
const moodScore = entryMoodScore(entry);
if (moodScore === null || entry.stress === null) return null;
return { stress: entry.stress, moodScore };
})
.filter((point): point is { stress: number; moodScore: number } => point !== null);
if (points.length === 0) {
return { points: [], avgStress: null, avgMood: null };
}
const avgStress = points.reduce((sum, p) => sum + p.stress, 0) / points.length;
const avgMood = points.reduce((sum, p) => sum + p.moodScore, 0) / points.length;
return { points, avgStress, avgMood };
}
export function weekOverWeekTrend(
entries: JournalEntryPoint[],
now = new Date(),
): { current: number | null; previous: number | null; delta: number | null } {
const currentStart = startOfWeekMonday(now);
const currentEnd = addDays(currentStart, 7);
const previousStart = addDays(currentStart, -7);
const currentScores = entries
.filter((e) => inRange(new Date(e.recordedAt), currentStart, currentEnd))
.map(entryMoodScore)
.filter((v): v is number => v !== null);
const previousScores = entries
.filter((e) => inRange(new Date(e.recordedAt), previousStart, currentStart))
.map(entryMoodScore)
.filter((v): v is number => v !== null);
const current =
currentScores.length > 0
? currentScores.reduce((sum, v) => sum + v, 0) / currentScores.length
: null;
const previous =
previousScores.length > 0
? previousScores.reduce((sum, v) => sum + v, 0) / previousScores.length
: null;
const delta = current !== null && previous !== null ? current - previous : null;
return { current, previous, delta };
}
export function moodCatalogForClient() {
return MOOD_CATALOG.map(({ id, label, emoji, color }) => ({ id, label, emoji, color }));
}
export function moodLabel(id: string): string {
return getMoodById(id)?.label ?? id;
}
function averageEntryMoodScores(entries: JournalEntryPoint[]): number | null {
const scores = entries.map(entryMoodScore).filter((v): v is number => v !== null);
if (scores.length === 0) return null;
return scores.reduce((sum, v) => sum + v, 0) / scores.length;
}
function addDays(date: Date, delta: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + delta);
return next;
}
function startOfWeekMonday(date: Date): Date {
const next = new Date(date);
const day = next.getDay();
const diffToMonday = (day + 6) % 7;
next.setHours(0, 0, 0, 0);
next.setDate(next.getDate() - diffToMonday);
return next;
}
function inRange(date: Date, start: Date, end: Date): boolean {
return date >= start && date < end;
}