feat: add form components and UI elements for enhanced user interaction
- Introduced Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage components for structured form handling. - Added Input, Textarea, and Select components for user input. - Implemented Label and Separator components for better UI organization. - Created Skeleton component for loading states. - Developed Toast and Toaster components for user notifications. - Integrated useToast hook for managing toast notifications. - Established AI analysis functionality with analyzeJournalWithLocalAI. - Set up Dexie for local database management with Journal and Task models. - Configured Supabase client for server-side and browser-side interactions. - Defined database schema for journals, tasks, chat sessions, and messages with Row Level Security policies. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
co-authored by
Copilot
parent
3432bbab33
commit
c73bf0e499
+55
-32
@@ -1,35 +1,58 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: #f5f7f4;
|
||||
@layer base {
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(47, 125, 99, 0.08), transparent 34%),
|
||||
linear-gradient(315deg, rgba(216, 100, 74, 0.08), transparent 30%),
|
||||
#f5f7f4;
|
||||
color: #17211f;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { db } from "@/lib/db";
|
||||
import { analyzeJournalWithLocalAI } from "@/lib/ai";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const moods = [
|
||||
{ id: "happy", label: "Mutlu", emoji: "😊" },
|
||||
{ id: "neutral", label: "Nötr", emoji: "😐" },
|
||||
{ id: "sad", label: "Üzgün", emoji: "😔" },
|
||||
{ id: "angry", label: "Sinirli", emoji: "😠" },
|
||||
];
|
||||
|
||||
export default function JournalPage() {
|
||||
const [content, setContent] = useState("");
|
||||
const [mood, setMood] = useState("happy");
|
||||
const [energy, setEnergy] = useState("3");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Dexie.js üzerinden günlükleri tarih sırasına göre çekiyoruz (en yeni en üstte)
|
||||
const journals = useLiveQuery(
|
||||
() => db.journals.orderBy("date").reverse().toArray()
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const now = new Date().toISOString();
|
||||
const entryId = uuidv4();
|
||||
const currentContent = content; // Analiz için metni kopyala
|
||||
|
||||
try {
|
||||
// 1. Veriyi veritabanına hemen ekle (Kullanıcı beklemesin)
|
||||
await db.journals.add({
|
||||
id: entryId,
|
||||
date: now.split("T")[0],
|
||||
mood,
|
||||
energy: parseInt(energy, 10),
|
||||
content: currentContent,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
// UI'ı Sıfırla
|
||||
setContent("");
|
||||
setEnergy("3");
|
||||
setMood("happy");
|
||||
|
||||
// 2. Arka planda AI Analizi başlat
|
||||
try {
|
||||
const aiResult = await analyzeJournalWithLocalAI(currentContent);
|
||||
if (aiResult) {
|
||||
await db.journals.update(entryId, {
|
||||
ai_tags: aiResult.ai_tags,
|
||||
ai_sentiment_score: aiResult.ai_sentiment_score,
|
||||
ai_summary: aiResult.ai_summary,
|
||||
});
|
||||
|
||||
// Eğer AI görev önerdiyse Görevler tablosuna at (pending / onay bekliyor yapısı eklenebilir ama direkt atalım)
|
||||
if (aiResult.suggested_tasks && aiResult.suggested_tasks.length > 0) {
|
||||
for (const taskTitle of aiResult.suggested_tasks) {
|
||||
await db.tasks.add({
|
||||
id: uuidv4(),
|
||||
journal_id: entryId,
|
||||
title: `AI Önerisi: ${taskTitle}`,
|
||||
status: "todo",
|
||||
ai_generated: true,
|
||||
date: now.split("T")[0],
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (aiErr) {
|
||||
console.error("Arka plan AI analizi hatası:", aiErr);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Günlük kaydedilemedi:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if(confirm("Bu günlüğü silmek istediğinden emin misin?")) {
|
||||
await db.journals.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Günlük</h1>
|
||||
<p className="text-muted-foreground">Zihnini boşalt, hislerini kaydet. Verilerin sadece cihazında kalır.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Yeni Kayıt</CardTitle>
|
||||
<CardDescription>Bugün nasıl hissediyorsun?</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Ruh Hali</Label>
|
||||
<div className="flex gap-2">
|
||||
{moods.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setMood(m.id)}
|
||||
className={`flex-1 py-2 px-1 rounded-md border text-xl flex items-center justify-center transition-colors ${
|
||||
mood === m.id ? "bg-primary/20 border-primary" : "bg-card border-border hover:bg-muted"
|
||||
}`}
|
||||
title={m.label}
|
||||
>
|
||||
{m.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Enerji Seviyesi (1-5): {energy}</Label>
|
||||
<Input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={energy}
|
||||
onChange={(e) => setEnergy(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">Düşüncelerini buraya dök...</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[150px] resize-y"
|
||||
placeholder="Örneğin: Bugün toplantıda işler ters gitti..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full md:w-auto">
|
||||
{isSubmitting ? "Kaydediliyor..." : "Kaydet"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<h2 className="text-xl font-bold">Geçmiş Kayıtlar</h2>
|
||||
{!journals ? (
|
||||
<p className="text-sm text-muted-foreground">Yükleniyor...</p>
|
||||
) : journals.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Henüz kayıt bulunmuyor.</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{journals.map((journal) => (
|
||||
<Card key={journal.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<span>{moods.find(m => m.id === journal.mood)?.emoji}</span>
|
||||
<span>{journal.date}</span>
|
||||
</CardTitle>
|
||||
<span className="text-xs px-2 py-1 bg-muted rounded-md font-medium text-muted-foreground">
|
||||
Enerji: {journal.energy}/5
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{journal.content}</p>
|
||||
|
||||
{/* AI Sonuçlarını Göster */}
|
||||
{journal.ai_tags && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{journal.ai_tags.map(tag => (
|
||||
<span key={tag} className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-md">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{journal.ai_summary && (
|
||||
<div className="mt-3 p-3 bg-muted/40 rounded-lg border text-sm text-muted-foreground italic border-l-2 border-l-primary">
|
||||
" {journal.ai_summary} "
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-500/10" onClick={() => handleDelete(journal.id)}>Sil</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
-4
@@ -1,9 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mood Tracker MVP",
|
||||
description: "LocalStorage tabanli minimal mood dashboard uygulamasi.",
|
||||
title: "MindSpace",
|
||||
description: "AI Destekli Kişisel Yaşam ve Planlama Dashboard'u",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -12,8 +19,25 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="tr">
|
||||
<body>{children}</body>
|
||||
<html lang="tr" className={cn("font-sans", geist.variable)} suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 h-screen overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6 md:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const data = {
|
||||
email: formData.get('email') as string,
|
||||
password: formData.get('password') as string,
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword(data)
|
||||
|
||||
if (error) {
|
||||
// Ideally, pass this error to the UI via URL params or state
|
||||
redirect('/login?error=true')
|
||||
}
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
export async function signup(formData: FormData) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const data = {
|
||||
email: formData.get('email') as string,
|
||||
password: formData.get('password') as string,
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signUp(data)
|
||||
|
||||
if (error) {
|
||||
redirect('/login?error=true')
|
||||
}
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
redirect('/')
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { login, signup } from './actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">MindSpace</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Hesabınıza giriş yapın veya yeni hesap oluşturun
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input id="email" name="email" type="email" placeholder="ornek@mail.com" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2 mt-6">
|
||||
<Button formAction={login} className="w-full">
|
||||
Giriş Yap
|
||||
</Button>
|
||||
<Button formAction={signup} variant="outline" className="w-full">
|
||||
Kayıt Ol
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import logo from "../assets/logo.png";
|
||||
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 className="flex items-center gap-4">
|
||||
<Image
|
||||
src={logo}
|
||||
alt="Mood Tracker logo"
|
||||
className="h-16 w-16 rounded-md object-contain sm:h-20 sm:w-20"
|
||||
priority
|
||||
/>
|
||||
<h1 className="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);
|
||||
}
|
||||
+176
-410
@@ -1,442 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import logo from "../assets/logo.png";
|
||||
import { useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Activity, Brain, PenTool, CheckCircle } from "lucide-react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend
|
||||
} 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;
|
||||
const moodScores: Record<string, number> = { happy: 4, neutral: 3, sad: 2, angry: 1 };
|
||||
|
||||
type MoodEntry = {
|
||||
id: string;
|
||||
date: string;
|
||||
mood: Mood;
|
||||
mood_score: MoodScore;
|
||||
energy: Energy;
|
||||
};
|
||||
export default function DashboardPage() {
|
||||
const journals = useLiveQuery(() => db.journals.orderBy("date").toArray()) || [];
|
||||
const tasks = useLiveQuery(() => db.tasks.toArray()) || [];
|
||||
|
||||
const pendingTasksCount = tasks.filter(t => t.status === "todo").length;
|
||||
|
||||
// Bugüne ait enerji seviyesi
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const todaysJournal = journals.find(j => j.date === today);
|
||||
const todaysEnergy = todaysJournal ? todaysJournal.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));
|
||||
// 1. Grafik: Son 7 günün Ruh Hali ve Enerji trendi
|
||||
const trendData = useMemo(() => {
|
||||
const dataMap: Record<string, { date: string, moodSum: number, energySum: number, count: number }> = {};
|
||||
|
||||
journals.forEach(j => {
|
||||
if (!dataMap[j.date]) {
|
||||
dataMap[j.date] = { date: j.date, moodSum: 0, energySum: 0, count: 0 };
|
||||
}
|
||||
} 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);
|
||||
dataMap[j.date].moodSum += moodScores[j.mood] || 3;
|
||||
dataMap[j.date].energySum += j.energy;
|
||||
dataMap[j.date].count += 1;
|
||||
});
|
||||
|
||||
setMessage("Kayıt kaydedildi. Dashboard güncellendi.");
|
||||
}
|
||||
return Object.values(dataMap)
|
||||
.map(d => ({
|
||||
date: d.date.slice(5), // Sadece MM-DD formatı alalım
|
||||
Mood: Number((d.moodSum / d.count).toFixed(1)),
|
||||
Enerji: Number((d.energySum / d.count).toFixed(1)),
|
||||
}))
|
||||
.slice(-7); // Sadece son 7 günü göster
|
||||
}, [journals]);
|
||||
|
||||
// 2. Grafik: En çok kullanılan AI etiketleri
|
||||
const tagData = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
journals.forEach(j => {
|
||||
if (j.ai_tags) {
|
||||
j.ai_tags.forEach(tag => {
|
||||
counts[tag] = (counts[tag] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
return Object.entries(counts)
|
||||
.map(([name, value]) => ({ name, Değer: value }))
|
||||
.sort((a, b) => b.Değer - a.Değer)
|
||||
.slice(0, 5); // En çok geçen 5 etiket
|
||||
}, [journals]);
|
||||
|
||||
// Son günlüğe ait AI Summary
|
||||
const lastInsight = [...journals].reverse().find(j => j.ai_summary)?.ai_summary;
|
||||
|
||||
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 className="flex items-center gap-4">
|
||||
<Image
|
||||
src={logo}
|
||||
alt="Mood Tracker logo"
|
||||
className="h-16 w-16 rounded-md object-contain sm:h-20 sm:w-20"
|
||||
priority
|
||||
/>
|
||||
<h1 className="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>
|
||||
<div className="space-y-6 max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bugün Nasılsın?</h1>
|
||||
<p className="text-muted-foreground">Kişisel özetin ve yapay zeka analizlerin burada görünecek.</p>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Günlük Kayıtları</CardTitle>
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{journals.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{journals.length === 0 ? "Henüz kayıt girilmedi" : "Toplam kayıt eklendi"}
|
||||
</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}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Aktif Görevler</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{pendingTasksCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Bekleyen görev
|
||||
</p>
|
||||
) : null}
|
||||
</motion.form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Bugünkü Enerji</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{todaysEnergy}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{todaysJournal ? "/ 5 Seviyesinde" : "Kayıt bekleniyor"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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 }}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Local AI Durumu</CardTitle>
|
||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-500">Hazır</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Ollama / Veri bekliyor
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7 ">
|
||||
{/* Line Chart */}
|
||||
<Card className="lg:col-span-4 p-6 flex flex-col justify-between">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium mb-1">Ruh Hali & Enerji Trendi</h3>
|
||||
<p className="text-sm text-muted-foreground">Son 7 günlük ortalamalar (1-5 Arası Puanlama)</p>
|
||||
</div>
|
||||
<div className="h-[250px] w-full">
|
||||
{trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#88888833" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
|
||||
<YAxis domain={[1, 5]} axisLine={false} tickLine={false} tick={{ fontSize: 12 }} width={30} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: "8px", backgroundColor: "#fff", color: "#000", border: "none" }}
|
||||
itemStyle={{ fontWeight: "500" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ paddingTop: "10px", fontSize: "14px" }} />
|
||||
<Line type="monotone" dataKey="Mood" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4 }} activeDot={{ r: 6 }} />
|
||||
<Line type="monotone" dataKey="Enerji" stroke="#10b981" strokeWidth={3} dot={{ r: 4 }} activeDot={{ r: 6 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState text="Trend grafiği için ilk mood kaydını ekle." />
|
||||
<div className="h-full flex items-center justify-center border-2 border-dashed rounded-md bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">Veri bekleniyor...</p>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bar Chart & Insight */}
|
||||
<div className="lg:col-span-3 space-y-4 flex flex-col">
|
||||
<Card className="p-6 flex-1 flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium mb-1">AI Konu Dağılımı</h3>
|
||||
<p className="text-sm text-muted-foreground">Günlüklerinden çıkarılan en sık 5 etiket</p>
|
||||
</div>
|
||||
<div className="h-[150px] w-full mt-auto">
|
||||
{tagData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={tagData} layout="vertical" margin={{ left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#88888833" />
|
||||
<XAxis type="number" hide />
|
||||
<YAxis dataKey="name" type="category" axisLine={false} tickLine={false} tick={{ fontSize: 13 }} width={90} />
|
||||
<Tooltip
|
||||
cursor={{fill: 'transparent'}}
|
||||
contentStyle={{ borderRadius: "8px", backgroundColor: "#fff", color: "#000", border: "none" }}
|
||||
/>
|
||||
<Bar dataKey="Değer" fill="#8b5cf6" radius={[0, 4, 4, 0]} barSize={20} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState text="Dağılım grafiği kayıt eklendikten sonra görünür." />
|
||||
<div className="h-full flex items-center justify-center border-2 border-dashed rounded-md bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">Yeterli etiket yok.</p>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<Card className="p-6 bg-primary/5 border-primary/20 flex-1">
|
||||
<h3 className="text-lg font-medium mb-3 flex items-center gap-2">
|
||||
<Brain className="w-5 h-5 text-primary" /> Son AI İçgörüsü
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed italic">
|
||||
{lastInsight ? `"${lastInsight}"` : "Henüz bir içgörü oluşmadı. Biraz günlük yaz, AI analiz yapsın."}
|
||||
</p>
|
||||
</Card>
|
||||
</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>
|
||||
</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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { db } from "@/lib/db";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export default function TasksPage() {
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const tasks = useLiveQuery(
|
||||
() => db.tasks.toArray() // Şimdilik hepsini çekiyoruz, sıralama yapılabilir
|
||||
);
|
||||
|
||||
const handleAddTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await db.tasks.add({
|
||||
id: uuidv4(),
|
||||
title,
|
||||
status: "todo",
|
||||
ai_generated: false,
|
||||
date: now.split("T")[0],
|
||||
created_at: now,
|
||||
});
|
||||
setTitle("");
|
||||
} catch (error) {
|
||||
console.error("Görev eklenemedi:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = async (id: string, currentStatus: string) => {
|
||||
await db.tasks.update(id, {
|
||||
status: currentStatus === "todo" ? "completed" : "todo",
|
||||
});
|
||||
};
|
||||
|
||||
const deleteTask = async (id: string) => {
|
||||
await db.tasks.delete(id);
|
||||
};
|
||||
|
||||
const pendingTasks = tasks?.filter(t => t.status === "todo") || [];
|
||||
const completedTasks = tasks?.filter(t => t.status === "completed") || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Görevler & Planlar</h1>
|
||||
<p className="text-muted-foreground">Kişisel hedeflerin ve YZ tarafından önerilen aksiyonlar.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Yeni Görev</CardTitle>
|
||||
<CardDescription>Aklındakini aksiyona dönüştür.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAddTask} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Örn: 15 dakika yürüyüş yap..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit">Ekle</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 pt-4">
|
||||
{/* Yapılacaklar */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
Yapılacaklar <span className="text-sm px-2 py-0.5 rounded-full bg-primary/20 text-primary">{pendingTasks.length}</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Bekleyen görev yok.</p>
|
||||
) : (
|
||||
pendingTasks.map(task => (
|
||||
<div key={task.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card shadow-sm group transition-all hover:border-primary/50">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => toggleTaskStatus(task.id, task.status)}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-medium">{task.title}</span>
|
||||
<button onClick={() => deleteTask(task.id)} className="opacity-0 group-hover:opacity-100 p-1.5 text-muted-foreground hover:text-red-500 transition-all rounded-md hover:bg-red-500/10">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tamamlananlar */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold opacity-70 flex items-center gap-2">
|
||||
Tamamlananlar <span className="text-sm px-2 py-0.5 rounded-full bg-muted text-muted-foreground">{completedTasks.length}</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{completedTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Hiç görev tamamlanmadı.</p>
|
||||
) : (
|
||||
completedTasks.map(task => (
|
||||
<div key={task.id} className="flex items-center gap-3 p-3 rounded-lg border bg-muted/40 shadow-sm group">
|
||||
<Checkbox
|
||||
checked={true}
|
||||
onCheckedChange={() => toggleTaskStatus(task.id, task.status)}
|
||||
className="w-5 h-5 data-[state=checked]:bg-muted-foreground data-[state=checked]:border-muted-foreground"
|
||||
/>
|
||||
<span className="flex-1 text-sm line-through text-muted-foreground">{task.title}</span>
|
||||
<button onClick={() => deleteTask(task.id)} className="opacity-0 group-hover:opacity-100 p-1.5 text-muted-foreground hover:text-red-500 transition-all rounded-md hover:bg-red-500/10">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user