Files
neta/app/page.tsx
T

438 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { motion } from "framer-motion";
import {
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { FormEvent, useEffect, useMemo, useState } from "react";
type Mood = "happy" | "neutral" | "sad" | "angry";
type MoodScore = 4 | 3 | 2 | 1;
type Energy = 1 | 2 | 3 | 4 | 5;
type MoodEntry = {
id: string;
date: string;
mood: Mood;
mood_score: MoodScore;
energy: Energy;
};
const STORAGE_KEY = "mood-tracker-entries";
const moodConfig: Record<
Mood,
{ label: string; icon: string; score: MoodScore; color: string }
> = {
happy: { label: "Mutlu", icon: "😊", score: 4, color: "#2f7d63" },
neutral: { label: "Nötr", icon: "😐", score: 3, color: "#4f7fbf" },
sad: { label: "Üzgün", icon: "😔", score: 2, color: "#e3aa38" },
angry: { label: "Sinirli", icon: "😠", score: 1, color: "#d8644a" },
};
const moodOptions = Object.entries(moodConfig) as Array<
[Mood, (typeof moodConfig)[Mood]]
>;
const today = () => new Date().toISOString().slice(0, 10);
const createId = () =>
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
export default function Home() {
const [entries, setEntries] = useState<MoodEntry[]>([]);
const [date, setDate] = useState(today);
const [mood, setMood] = useState<Mood>("happy");
const [energy, setEnergy] = useState<Energy>(3);
const [message, setMessage] = useState("");
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const rawEntries = window.localStorage.getItem(STORAGE_KEY);
if (!rawEntries) {
setIsLoaded(true);
return;
}
try {
const parsedEntries = JSON.parse(rawEntries) as MoodEntry[];
if (Array.isArray(parsedEntries)) {
setEntries(sortEntries(parsedEntries));
}
} catch {
window.localStorage.removeItem(STORAGE_KEY);
} finally {
setIsLoaded(true);
}
}, []);
useEffect(() => {
if (!isLoaded) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}, [entries, isLoaded]);
const lineData = useMemo(
() =>
entries.map((entry) => ({
date: formatShortDate(entry.date),
mood: entry.mood_score,
energy: entry.energy,
})),
[entries],
);
const pieData = useMemo(
() =>
moodOptions
.map(([key, config]) => ({
name: config.label,
value: entries.filter((entry) => entry.mood === key).length,
color: config.color,
}))
.filter((item) => item.value > 0),
[entries],
);
const insights = useMemo(() => buildInsights(entries), [entries]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!date) {
setMessage("Lütfen tarih seç.");
return;
}
if (!mood) {
setMessage("Lütfen ruh halini seç.");
return;
}
if (energy < 1 || energy > 5) {
setMessage("Enerji seviyesi 1 ile 5 arasında olmalı.");
return;
}
const moodScore = moodConfig[mood].score;
setEntries((currentEntries) => {
const existingEntry = currentEntries.find((entry) => entry.date === date);
const nextEntry: MoodEntry = {
id: existingEntry?.id ?? createId(),
date,
mood,
mood_score: moodScore,
energy,
};
const nextEntries = existingEntry
? currentEntries.map((entry) =>
entry.date === date ? nextEntry : entry,
)
: [...currentEntries, nextEntry];
return sortEntries(nextEntries);
});
setMessage("Kayıt kaydedildi. Dashboard güncellendi.");
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
<header className="flex flex-col gap-2 border-b border-ink/10 pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-leaf">
Mood Tracker
</p>
<h1 className="mt-2 text-3xl font-bold text-ink sm:text-4xl">
Günlük ruh hali dashboard'u
</h1>
</div>
<div className="rounded-md border border-ink/10 bg-white px-4 py-3 text-sm text-ink/70 shadow-soft">
{entries.length} kayıt
</div>
</header>
<section className="grid gap-5 lg:grid-cols-[360px_1fr]">
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
onSubmit={handleSubmit}
className="rounded-lg border border-ink/10 bg-white p-5 shadow-soft"
>
<div className="mb-5">
<h2 className="text-xl font-semibold text-ink">Bugünün kaydı</h2>
<p className="mt-1 text-sm text-ink/60">
Aynı tarih tekrar kaydedilirse eski kayıt güncellenir.
</p>
</div>
<label className="block text-sm font-medium text-ink" htmlFor="date">
Tarih
</label>
<input
id="date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
className="mt-2 w-full rounded-md border border-ink/15 bg-mist px-3 py-2 outline-none transition focus:border-leaf focus:ring-2 focus:ring-leaf/20"
/>
<fieldset className="mt-5">
<legend className="text-sm font-medium text-ink">Ruh hali</legend>
<div className="mt-2 grid grid-cols-2 gap-2">
{moodOptions.map(([key, config]) => (
<button
key={key}
type="button"
onClick={() => setMood(key)}
className={`flex min-h-20 flex-col items-center justify-center rounded-md border px-3 py-3 text-center transition ${
mood === key
? "border-leaf bg-leaf text-white"
: "border-ink/10 bg-mist text-ink hover:border-leaf/60"
}`}
>
<span className="text-2xl" aria-hidden="true">
{config.icon}
</span>
<span className="mt-1 text-sm font-semibold">
{config.label}
</span>
</button>
))}
</div>
</fieldset>
<label
className="mt-5 block text-sm font-medium text-ink"
htmlFor="energy"
>
Enerji seviyesi: {energy}
</label>
<input
id="energy"
type="range"
min="1"
max="5"
step="1"
value={energy}
onChange={(event) => setEnergy(Number(event.target.value) as Energy)}
className="mt-3 w-full accent-leaf"
/>
<div className="mt-1 flex justify-between text-xs text-ink/50">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
</div>
<button
type="submit"
className="mt-6 w-full rounded-md bg-ink px-4 py-3 text-sm font-semibold text-white transition hover:bg-leaf"
>
Kaydet
</button>
{message ? (
<p className="mt-3 rounded-md bg-mist px-3 py-2 text-sm text-ink/70">
{message}
</p>
) : null}
</motion.form>
<div className="grid gap-5">
<ChartCard title="Mood ve enerji trendi">
{entries.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={lineData} margin={{ left: 0, right: 16 }}>
<XAxis dataKey="date" tickLine={false} axisLine={false} />
<YAxis
domain={[1, 5]}
tickCount={5}
tickLine={false}
axisLine={false}
/>
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="mood"
name="Mood score"
stroke="#2f7d63"
strokeWidth={3}
dot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="energy"
name="Enerji"
stroke="#d8644a"
strokeWidth={3}
dot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState text="Trend grafiği için ilk mood kaydını ekle." />
)}
</ChartCard>
<div className="grid gap-5 xl:grid-cols-[1fr_1fr]">
<ChartCard title="Mood dağılımı">
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
innerRadius={56}
outerRadius={92}
paddingAngle={3}
>
{pieData.map((item) => (
<Cell key={item.name} fill={item.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<EmptyState text="Dağılım grafiği kayıt eklendikten sonra görünür." />
)}
</ChartCard>
<ChartCard title="Insight">
{insights.length > 0 ? (
<ul className="space-y-3">
{insights.map((insight) => (
<li
key={insight}
className="rounded-md border border-ink/10 bg-mist px-3 py-3 text-sm leading-6 text-ink/75"
>
{insight}
</li>
))}
</ul>
) : (
<EmptyState text="Insight üretmek için en az bir kayıt ekle." />
)}
</ChartCard>
</div>
</div>
</section>
</main>
);
}
function ChartCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<motion.section
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
className="rounded-lg border border-ink/10 bg-white p-5 shadow-soft"
>
<h2 className="mb-4 text-lg font-semibold text-ink">{title}</h2>
{children}
</motion.section>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="flex min-h-56 items-center justify-center rounded-md border border-dashed border-ink/15 bg-mist px-4 text-center text-sm text-ink/55">
{text}
</div>
);
}
function sortEntries(entries: MoodEntry[]) {
return [...entries].sort((a, b) => a.date.localeCompare(b.date));
}
function formatShortDate(date: string) {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "short",
}).format(new Date(`${date}T00:00:00`));
}
function buildInsights(entries: MoodEntry[]) {
if (entries.length === 0) {
return [];
}
const latestSeven = [...entries]
.sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 7);
const moodAverage = average(latestSeven.map((entry) => entry.mood_score));
const energyAverage = average(latestSeven.map((entry) => entry.energy));
const mostFrequentMood = getMostFrequentMood(entries);
const insights = [
moodAverage >= 3
? "Son 7 kayıtta genel ruh halin pozitif görünüyor."
: "Son 7 kayıtta ruh hali ortalaman düşük görünüyor.",
];
if (energyAverage < 3) {
insights.push("Enerji seviyen son kayıtlarda düşük seyrediyor.");
} else {
insights.push("Enerji seviyen son kayıtlarda dengeli görünüyor.");
}
if (mostFrequentMood) {
insights.push(
`En sık görülen ruh halin: ${moodConfig[mostFrequentMood].label}.`,
);
}
return insights;
}
function average(values: number[]) {
return values.reduce((total, value) => total + value, 0) / values.length;
}
function getMostFrequentMood(entries: MoodEntry[]) {
const counts = entries.reduce<Record<Mood, number>>(
(currentCounts, entry) => {
currentCounts[entry.mood] += 1;
return currentCounts;
},
{ happy: 0, neutral: 0, sad: 0, angry: 0 },
);
return moodOptions.reduce<Mood | null>((winner, [moodKey]) => {
if (!winner || counts[moodKey] > counts[winner]) {
return moodKey;
}
return winner;
}, null);
}