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
8 changes: 8 additions & 0 deletions frontend/app/explore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from 'next/link';
import { motion } from 'framer-motion';
import { MoodCard } from '@/components/MoodCard';
import { FloatingBackground } from '@/components/FloatingBackground';
import RandomMoodButton from '@/components/RandomMoodButton';
import { Heart, BarChart3, Music, ArrowLeft, Search, X } from 'lucide-react';
import { AnimatePresence } from 'framer-motion';
import { ThemeToggle } from '@/components/ThemeToggle';
Expand Down Expand Up @@ -300,6 +301,13 @@ export default function ExplorePage() {
</motion.div>
)}

{/* Surprise Me Button */}
{mounted && (
<div className="mt-8 flex flex-col items-center gap-4">
<RandomMoodButton moods={moods} />
</div>
)}

{/* Continue Button */}
{selectedMood && (
<motion.div
Expand Down
59 changes: 59 additions & 0 deletions frontend/components/ProductivityTips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { motion } from 'framer-motion';
import { CheckCircle2, Sparkles } from 'lucide-react';
import { Mood } from '@/types/mood';

interface ProductivityTipsProps {
mood: Mood;
tips: string[];
}

export function ProductivityTips({ mood, tips }: ProductivityTipsProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35 }}
className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg rounded-3xl p-6 shadow-lg border border-white/50 dark:border-gray-700/50"
>
<div className="flex items-start gap-4">
<div
className="flex h-12 w-12 items-center justify-center rounded-3xl border border-white/60 text-primary"
style={{
background: `linear-gradient(135deg, ${mood.color}20, ${mood.glow}20)`,
}}
>
<Sparkles className="w-6 h-6" />
</div>

<div className="flex-1">
<p className="text-xs uppercase tracking-[0.24em] font-semibold text-primary/80">
Productivity Tips
</p>
<h3 className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Work with your current mood
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Actions that naturally fit how you feel right now.
</p>
</div>
</div>

<div className="mt-5 space-y-3">
{tips.map((tip, index) => (
<motion.div
key={`${tip}-${index}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 + index * 0.05 }}
className="flex items-start gap-3 rounded-2xl border border-gray-200/70 bg-gray-50/70 dark:bg-white/5 p-4"
>
<CheckCircle2 className="mt-1 w-5 h-5 text-primary" />
<p className="text-sm leading-6 text-gray-700 dark:text-gray-200">
{tip}
</p>
</motion.div>
))}
</div>
</motion.div>
);
}
58 changes: 58 additions & 0 deletions frontend/components/RandomMoodButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Dice1 } from "lucide-react";

interface MoodOption {
id: string;
name: string;
emoji: string;
}

interface RandomMoodButtonProps {
moods: MoodOption[];
}

export default function RandomMoodButton({ moods }: RandomMoodButtonProps) {
const router = useRouter();
const [isRolling, setIsRolling] = useState(false);
const [selectedMood, setSelectedMood] = useState<MoodOption | null>(null);

const handleRandomMood = () => {
if (isRolling || moods.length === 0) return;

const randomIndex = Math.floor(Math.random() * moods.length);
const mood = moods[randomIndex];
setSelectedMood(mood);
setIsRolling(true);

setTimeout(() => {
router.push(`/mood/${mood.id}`);
}, 450);
};

return (
<div className="w-full max-w-xl mx-auto">
<motion.button
whileHover={{ scale: 1.02, y: -1 }}
whileTap={{ scale: 0.96, rotate: isRolling ? 15 : 0 }}
onClick={handleRandomMood}
disabled={isRolling}
className="w-full flex items-center justify-center gap-3 px-6 py-4 rounded-3xl border border-white/20 bg-white/15 text-white shadow-[0_20px_60px_rgba(109,40,217,0.18)] backdrop-blur-xl transition-all duration-300 hover:border-pink-300/40 hover:shadow-[0_20px_60px_rgba(195,46,255,0.18)] focus:outline-none focus:ring-2 focus:ring-pink-400/50"
>
<Dice1 className={`w-5 h-5 ${isRolling ? "animate-spin" : ""}`} />
<span className="font-semibold text-sm sm:text-base">
{isRolling ? `Rolling for ${selectedMood?.name}...` : "🎲 Surprise Me"}
</span>
</motion.button>

<p className="mt-3 text-center text-sm text-muted-foreground dark:text-gray-300">
{isRolling
? "Hang tight β€” exploring a tone from the full emotion library."
: "Discover a random emotion and explore its insights."}
</p>
</div>
);
}
5 changes: 4 additions & 1 deletion frontend/components/SuggestionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { motion } from 'framer-motion';
import { RefreshCw, MessageCircle, Quote as QuoteIcon, Hash, Music } from 'lucide-react';
import { RefreshCw, MessageCircle, Hash, Music } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useMoodStore } from '@/lib/useMoodStore';
import {
Expand All @@ -15,6 +15,7 @@ import { QuoteSkeleton } from '@/components/QuoteSkeleton';
import { Quote } from '@/data/fallbackQuotes';
import { Mood, Suggestion } from '@/types/mood';
import { MoodQuoteCard } from '@/components/MoodQuoteCard';
import { ProductivityTips } from '@/components/ProductivityTips';

interface SuggestionPanelProps {
suggestions: Suggestion;
Expand Down Expand Up @@ -149,6 +150,8 @@ const handleSaveNotes = () => {
</div>
</motion.div>

<ProductivityTips mood={mood} tips={suggestions.productivityTips ?? []} />

{/* Music Soundscape (Spotify) */}
<motion.div
initial={{ opacity: 0, y: 20 }}
Expand Down
64 changes: 0 additions & 64 deletions frontend/lib/customMoods.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { useEffect, useState } from 'react';

export interface CustomMood {
id: string;
name: string;
Expand Down Expand Up @@ -138,65 +136,3 @@ export function getCombinedMoods(defaultMoods: Mood[]): Mood[] {
const customMoods = CustomMoodStorage.getCustomMoods();
return [...defaultMoods, ...customMoods];
}

/**
* Hook for listening to custom mood changes
*/
export function useCustomMoods(defaultMoods: Mood[] = []) {
const [customMoods, setCustomMoods] = useState<CustomMood[]>([]);
const isClient = typeof window !== 'undefined';

useEffect(() => {
if (!isClient) return;

// Load initial custom moods
const loadCustomMoods = () => {
const moods = CustomMoodStorage.getCustomMoods();
setCustomMoods(moods);
};

loadCustomMoods();

// Listen for custom mood updates
const handleCustomMoodsUpdate = (event: CustomEvent) => {
setCustomMoods(event.detail);
};

window.addEventListener('customMoodsUpdated', handleCustomMoodsUpdate as EventListener);

return () => {
window.removeEventListener('customMoodsUpdated', handleCustomMoodsUpdate as EventListener);
};
}, [isClient]);

if (!isClient) {
return {
allMoods: defaultMoods,
customMoods: [],
addCustomMood: () => Promise.reject(new Error('Not available on server')),
deleteCustomMood: () => false,
refreshMoods: () => { }
};
}

const addCustomMood = async (moodData: Omit<CustomMood, 'id' | 'isCustom' | 'createdAt'>): Promise<CustomMood> => {
return CustomMoodStorage.saveCustomMood(moodData);
};

const deleteCustomMood = (moodId: string): boolean => {
return CustomMoodStorage.deleteCustomMood(moodId);
};

const refreshMoods = () => {
const moods = CustomMoodStorage.getCustomMoods();
setCustomMoods(moods);
};

return {
allMoods: getCombinedMoods(defaultMoods),
customMoods,
addCustomMood,
deleteCustomMood,
refreshMoods
};
}
Loading
Loading