Ship journal entries with mood tracking, insights, and Recharts charts. Adds v1 API endpoints per ADR 0005 and migration 0020_journal_entries.
154 lines
4.7 KiB
TypeScript
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;
|
|
}
|