Skip to content
Merged
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
102 changes: 100 additions & 2 deletions frontend/app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import { useState, useMemo, useCallback } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowLeft, Activity, Heart, Calendar, Download, ChevronDown, Trash2 } from 'lucide-react';
import { ArrowLeft, Activity, Heart, Calendar, Download, ChevronDown, Trash2, Flame, TrendingUp } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis } from 'recharts';
import { ThemeToggle } from '@/components/ThemeToggle';
import MoodPieChart from '@/components/MoodPieChart';
import MoodBarChart from '@/components/MoodBarChart';
import { MoodStats } from '@/components/MoodStats';
import { buildHourlyDistribution, buildMoodTrendData, getMoodStreakSummary } from '@/lib/moodAnalytics';
import { useMoodStore } from '@/lib/useMoodStore';
import { useServerAnalytics } from '@/hooks/useServerAnalytics';

Expand Down Expand Up @@ -71,6 +73,39 @@ export default function AnalyticsPage() {
}));
}, [stats.moodCounts]);

const trendData = useMemo(() => {
return buildMoodTrendData(
moodHistory.map(entry => ({
timestamp: entry.timestamp,
mood: entry.emotion || entry.mood,
emotion: entry.emotion,
notes: entry.notes,
}))
);
}, [moodHistory]);

const hourlyData = useMemo(() => {
return buildHourlyDistribution(
moodHistory.map(entry => ({
timestamp: entry.timestamp,
mood: entry.emotion || entry.mood,
emotion: entry.emotion,
notes: entry.notes,
}))
);
}, [moodHistory]);

const streakSummary = useMemo(() => {
return getMoodStreakSummary(
moodHistory.map(entry => ({
timestamp: entry.timestamp,
mood: entry.emotion || entry.mood,
emotion: entry.emotion,
notes: entry.notes,
}))
);
}, [moodHistory]);

const handleClearHistory = () => {
if (confirm('Are you sure you want to clear your entire mood history?')) {
clearHistory();
Expand Down Expand Up @@ -200,6 +235,69 @@ export default function AnalyticsPage() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="grid lg:grid-cols-[1.1fr_0.9fr] gap-6"
>
<div className="bg-card/80 backdrop-blur-md rounded-3xl p-6 shadow-xl border border-border">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<h3 className="text-xl font-bold text-foreground">Weekly Mood Trend</h3>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fill: '#6b7280', fontSize: 12 }} />
<RechartsTooltip />
<Line type="monotone" dataKey="count" stroke="#8b5cf6" strokeWidth={3} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>

<div className="bg-card/80 backdrop-blur-md rounded-3xl p-6 shadow-xl border border-border">
<div className="mb-4 flex items-center gap-2">
<Flame className="w-5 h-5 text-orange-500" />
<h3 className="text-xl font-bold text-foreground">Peak Mood Hours</h3>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hourlyData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fill: '#6b7280', fontSize: 12 }} />
<RechartsTooltip />
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</motion.div>

<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="grid md:grid-cols-3 gap-4"
>
<div className="rounded-2xl border border-border bg-card/80 p-4 shadow-sm">
<p className="text-sm text-muted-foreground">Current streak</p>
<p className="mt-2 text-2xl font-semibold text-foreground">{streakSummary.currentStreak} day{streakSummary.currentStreak === 1 ? '' : 's'}</p>
</div>
<div className="rounded-2xl border border-border bg-card/80 p-4 shadow-sm">
<p className="text-sm text-muted-foreground">Longest streak</p>
<p className="mt-2 text-2xl font-semibold text-foreground">{streakSummary.longestStreak} day{streakSummary.longestStreak === 1 ? '' : 's'}</p>
</div>
<div className="rounded-2xl border border-border bg-card/80 p-4 shadow-sm">
<p className="text-sm text-muted-foreground">Latest reflection</p>
<p className="mt-2 text-lg font-semibold text-foreground">{streakSummary.latestEntryDate ? new Date(streakSummary.latestEntryDate).toLocaleDateString() : 'No entries yet'}</p>
</div>
</motion.div>

<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="grid md:grid-cols-2 gap-6"
>
<MoodPieChart data={moodData} />
Expand All @@ -209,7 +307,7 @@ export default function AnalyticsPage() {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
transition={{ delay: 0.35 }}
className="bg-card/80 backdrop-blur-md rounded-3xl p-8 shadow-xl border border-border"
>
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
Expand Down
43 changes: 43 additions & 0 deletions frontend/lib/moodAnalytics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildMoodTrendData, buildHourlyDistribution, getMoodStreakSummary } from './moodAnalytics.js';

test('buildMoodTrendData groups entries by day for the last seven days', () => {
const referenceDate = new Date('2024-01-10T12:00:00.000Z');
const entries = [
{ timestamp: '2024-01-05T09:00:00.000Z', mood: 'happy' },
{ timestamp: '2024-01-06T10:00:00.000Z', mood: 'calm' },
{ timestamp: '2024-01-06T12:00:00.000Z', mood: 'sad' },
{ timestamp: '2024-01-10T08:00:00.000Z', mood: 'angry' },
];

const data = buildMoodTrendData(entries, referenceDate);
assert.equal(data.length, 7);
assert.equal(data[data.length - 1].count, 1);
assert.equal(data[2].count, 2);
});

test('buildHourlyDistribution counts entries by hour', () => {
const entries = [
{ timestamp: '2024-01-10T09:15:00.000Z', mood: 'happy' },
{ timestamp: '2024-01-10T09:45:00.000Z', mood: 'calm' },
{ timestamp: '2024-01-10T18:00:00.000Z', mood: 'sad' },
];

const data = buildHourlyDistribution(entries);
assert.equal(data[9].count, 2);
assert.equal(data[18].count, 1);
});

test('getMoodStreakSummary returns current and longest streaks', () => {
const referenceDate = new Date('2024-01-10T12:00:00.000Z');
const entries = [
{ timestamp: '2024-01-08T09:00:00.000Z', mood: 'happy' },
{ timestamp: '2024-01-09T10:00:00.000Z', mood: 'calm' },
{ timestamp: '2024-01-10T12:00:00.000Z', mood: 'sad' },
];

const summary = getMoodStreakSummary(entries, referenceDate);
assert.equal(summary.currentStreak, 3);
assert.equal(summary.longestStreak, 3);
});
119 changes: 119 additions & 0 deletions frontend/lib/moodAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
export interface MoodAnalyticsEntry {
timestamp: string;
mood: string;
emotion?: string;
notes?: string;
}

export interface MoodTrendPoint {
label: string;
date: string;
count: number;
mood: string | null;
}

export interface HourlyPoint {
hour: string;
count: number;
}

export interface MoodStreakSummary {
currentStreak: number;
longestStreak: number;
latestEntryDate: string | null;
}

export function buildMoodTrendData(entries: MoodAnalyticsEntry[], referenceDate: Date = new Date()): MoodTrendPoint[] {
const days = Array.from({ length: 7 }, (_, index) => {
const date = new Date(referenceDate);
date.setUTCDate(referenceDate.getUTCDate() - (6 - index));
return date;
});

return days.map((day) => {
const start = new Date(day);
start.setUTCHours(0, 0, 0, 0);
const end = new Date(day);
end.setUTCHours(23, 59, 59, 999);

const dayEntries = entries.filter((entry) => {
const timestamp = new Date(entry.timestamp);
return timestamp >= start && timestamp <= end;
});

const dominantMood = dayEntries.reduce<Record<string, number>>((acc, entry) => {
const mood = entry.emotion || entry.mood || 'unknown';
acc[mood] = (acc[mood] || 0) + 1;
return acc;
}, {});

const mostCommonMood = Object.entries(dominantMood).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;

return {
label: day.toLocaleDateString('en', { weekday: 'short' }),
date: day.toISOString().slice(0, 10),
count: dayEntries.length,
mood: mostCommonMood,
};
});
}

export function buildHourlyDistribution(entries: MoodAnalyticsEntry[]): HourlyPoint[] {
const buckets = Array.from({ length: 24 }, (_, hour) => ({ hour: `${hour.toString().padStart(2, '0')}:00`, count: 0 }));

entries.forEach((entry) => {
const hour = new Date(entry.timestamp).getUTCHours();
buckets[hour].count += 1;
});

return buckets;
}

export function getMoodStreakSummary(entries: MoodAnalyticsEntry[], referenceDate: Date = new Date()): MoodStreakSummary {
const sorted = [...entries]
.map((entry) => ({ ...entry, timestamp: new Date(entry.timestamp) }))
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());

const latestDate = sorted[0]?.timestamp ?? referenceDate;
const latestDay = new Date(latestDate);
latestDay.setUTCHours(0, 0, 0, 0);

const seenDays = new Set<string>();
sorted.forEach((entry) => {
const day = new Date(entry.timestamp);
day.setUTCHours(0, 0, 0, 0);
seenDays.add(day.toISOString().slice(0, 10));
});

const today = new Date(referenceDate);
today.setUTCHours(0, 0, 0, 0);

let currentStreak = 0;
const cursor = new Date(today);
while (seenDays.has(cursor.toISOString().slice(0, 10))) {
currentStreak += 1;
cursor.setDate(cursor.getDate() - 1);
}

let longestStreak = 0;
let tempStreak = 0;
const dates = Array.from(seenDays).sort();
let previousDate: Date | null = null;

dates.forEach((dateString) => {
const currentDate = new Date(dateString);
if (previousDate && (currentDate.getTime() - previousDate.getTime()) / (1000 * 60 * 60 * 24) === 1) {
tempStreak += 1;
} else {
tempStreak = 1;
}
longestStreak = Math.max(longestStreak, tempStreak);
previousDate = currentDate;
});

return {
currentStreak,
longestStreak,
latestEntryDate: latestDate.toISOString(),
};
}
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"postcss": "^8.5.6",
"tailwindcss": "^3.4.0",
"ts-jest": "^29.4.11",
"tsx": "^4.23.1",
"typescript": "^5.2.0"
},
"engines": {
Expand Down
Loading
Loading