feat: add sleep tracking features with leaderboard and user authentication
Some checks failed
Deploy / deploy (push) Failing after 50s

- Implemented a new HomePage component displaying the leaderboard.
- Created Providers component for session management using next-auth.
- Developed Header component for navigation and user authentication.
- Added Leaderboard component to fetch and display sleep data.
- Introduced SleepCard component for individual sleep entries.
- Created SleepForm component for logging sleep data with manual input options.
- Added SleepPhaseBar component to visualize sleep phases.
- Implemented utility functions for formatting and calculating sleep data.
- Set up authentication with Discord using next-auth and Prisma.
- Configured middleware for protected routes.
- Added TypeScript definitions for next-auth session.
- Configured Tailwind CSS for styling.
- Initialized TypeScript configuration for the project.
This commit is contained in:
2026-05-14 23:45:25 +02:00
parent 3c6ad58863
commit 06ff840762
34 changed files with 1671 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
function getDateRange(period: string): { gte?: Date; lte?: Date } {
const now = new Date();
if (period === "night") {
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setUTCHours(0, 0, 0, 0);
const end = new Date(yesterday);
end.setUTCHours(23, 59, 59, 999);
return { gte: yesterday, lte: end };
}
if (period === "week") {
const monday = new Date(now);
const day = monday.getDay();
const diff = day === 0 ? -6 : 1 - day;
monday.setDate(monday.getDate() + diff);
monday.setUTCHours(0, 0, 0, 0);
return { gte: monday };
}
if (period === "month") {
return { gte: new Date(now.getFullYear(), now.getMonth(), 1) };
}
if (period === "year") {
return { gte: new Date(now.getFullYear(), 0, 1) };
}
return {};
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const period = searchParams.get("period") || "week";
const dateRange = getDateRange(period);
const isNight = period === "night";
const users = await prisma.user.findMany({
include: {
sleepEntries: {
where: Object.keys(dateRange).length > 0 ? { date: dateRange } : undefined,
orderBy: { date: "desc" },
},
},
});
const leaderboard = users
.filter((u) => u.sleepEntries.length > 0)
.map((user) => {
const entries = user.sleepEntries;
const count = entries.length;
// Gisternacht = één entry, anders gemiddelde
const displayMinutes = isNight
? (entries[0]?.totalMinutes ?? 0)
: Math.round(entries.reduce((acc, e) => acc + e.totalMinutes, 0) / count);
const deepEntries = entries.filter((e) => e.deepMinutes !== null);
const lightEntries = entries.filter((e) => e.lightMinutes !== null);
const remEntries = entries.filter((e) => e.remMinutes !== null);
return {
id: user.id,
name: user.name ?? "Onbekend",
image: user.image,
displayMinutes,
isAverage: !isNight && count > 1,
avgDeep: deepEntries.length > 0
? Math.round(deepEntries.reduce((acc, e) => acc + (e.deepMinutes ?? 0), 0) / deepEntries.length)
: null,
avgLight: lightEntries.length > 0
? Math.round(lightEntries.reduce((acc, e) => acc + (e.lightMinutes ?? 0), 0) / lightEntries.length)
: null,
avgRem: remEntries.length > 0
? Math.round(remEntries.reduce((acc, e) => acc + (e.remMinutes ?? 0), 0) / remEntries.length)
: null,
entryCount: count,
lastEntry: entries[0]?.date ?? null,
};
})
.sort((a, b) => b.displayMinutes - a.displayMinutes);
return NextResponse.json(leaderboard);
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const entry = await prisma.sleepEntry.findUnique({ where: { id: params.id } });
if (!entry || entry.userId !== session.user.id) {
return NextResponse.json({ error: "Niet gevonden" }, { status: 404 });
}
await prisma.sleepEntry.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET /api/sleep?mine=true — get current user's entries
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const entries = await prisma.sleepEntry.findMany({
where: { userId: session.user.id },
orderBy: { date: "desc" },
});
return NextResponse.json(entries);
}
// POST /api/sleep — create or update entry for a given date
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const {
date,
bedtime,
wakeTime,
totalMinutes,
deepMinutes,
lightMinutes,
remMinutes,
awakeMinutes,
awakeCount,
notes,
} = body;
if (!date || !totalMinutes || totalMinutes < 1) {
return NextResponse.json(
{ error: "Datum en totale slaap zijn verplicht" },
{ status: 400 }
);
}
const userId = session.user.id;
const entryDate = new Date(date);
entryDate.setUTCHours(0, 0, 0, 0);
try {
const entry = await prisma.sleepEntry.upsert({
where: { userId_date: { userId, date: entryDate } },
update: {
bedtime: bedtime ? new Date(`${date}T${bedtime}`) : null,
wakeTime: wakeTime ? new Date(`${date}T${wakeTime}`) : null,
totalMinutes: Number(totalMinutes),
deepMinutes: deepMinutes ? Number(deepMinutes) : null,
lightMinutes: lightMinutes ? Number(lightMinutes) : null,
remMinutes: remMinutes ? Number(remMinutes) : null,
awakeMinutes: awakeMinutes ? Number(awakeMinutes) : null,
awakeCount: awakeCount ? Number(awakeCount) : null,
notes: notes || null,
},
create: {
userId,
date: entryDate,
bedtime: bedtime ? new Date(`${date}T${bedtime}`) : null,
wakeTime: wakeTime ? new Date(`${date}T${wakeTime}`) : null,
totalMinutes: Number(totalMinutes),
deepMinutes: deepMinutes ? Number(deepMinutes) : null,
lightMinutes: lightMinutes ? Number(lightMinutes) : null,
remMinutes: remMinutes ? Number(remMinutes) : null,
awakeMinutes: awakeMinutes ? Number(awakeMinutes) : null,
awakeCount: awakeCount ? Number(awakeCount) : null,
notes: notes || null,
},
});
return NextResponse.json(entry, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ error: "Opslaan mislukt" }, { status: 500 });
}
}

3
src/app/globals.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

121
src/app/history/page.tsx Normal file
View File

@@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
import { SleepCard } from "@/components/SleepCard";
import { formatDuration } from "@/lib/utils";
interface SleepEntry {
id: string;
date: string;
bedtime: string | null;
wakeTime: string | null;
totalMinutes: number;
deepMinutes: number | null;
lightMinutes: number | null;
remMinutes: number | null;
awakeMinutes: number | null;
awakeCount: number | null;
notes: string | null;
}
export default function HistoryPage() {
const [entries, setEntries] = useState<SleepEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/sleep")
.then((r) => r.json())
.then((d) => {
setEntries(d);
setLoading(false);
});
}, []);
function handleDelete(id: string) {
setEntries((prev) => prev.filter((e) => e.id !== id));
}
const avg =
entries.length > 0
? Math.round(entries.reduce((a, e) => a + e.totalMinutes, 0) / entries.length)
: null;
const best = entries.length > 0 ? Math.max(...entries.map((e) => e.totalMinutes)) : null;
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-100">📅 Mijn geschiedenis</h1>
<p className="text-slate-500 text-sm mt-1">Jouw slaaplogboek</p>
</div>
{/* Stats summary */}
{entries.length > 0 && (
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-slate-800/60 border border-slate-700/50 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-indigo-300">{entries.length}</div>
<div className="text-xs text-slate-500 mt-0.5">nachten</div>
</div>
<div className="bg-slate-800/60 border border-slate-700/50 rounded-xl p-3 text-center">
<div className="text-lg font-bold text-indigo-300">{avg ? formatDuration(avg) : ""}</div>
<div className="text-xs text-slate-500 mt-0.5">gemiddeld</div>
</div>
<div className="bg-slate-800/60 border border-slate-700/50 rounded-xl p-3 text-center">
<div className="text-lg font-bold text-green-400">{best ? formatDuration(best) : ""}</div>
<div className="text-xs text-slate-500 mt-0.5">beste nacht</div>
</div>
</div>
)}
{/* Mini bar chart */}
{entries.length > 0 && (
<div className="bg-slate-800/60 border border-slate-700/50 rounded-xl p-4 mb-6">
<p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-widest">Laatste 14 nachten</p>
<div className="flex items-end gap-1 h-16">
{entries.slice(0, 14).reverse().map((e) => {
const heightPct = Math.min(100, (e.totalMinutes / 600) * 100);
const isGood = e.totalMinutes >= 420 && e.totalMinutes <= 540;
return (
<div
key={e.id}
className="flex-1 flex flex-col items-center gap-1"
title={`${new Date(e.date).toLocaleDateString("nl-BE", { day: "numeric", month: "short" })}: ${formatDuration(e.totalMinutes)}`}
>
<div
className={`w-full rounded-t-sm transition-all ${isGood ? "bg-indigo-500" : e.totalMinutes < 420 ? "bg-red-500/70" : "bg-blue-500/70"}`}
style={{ height: `${heightPct}%` }}
/>
</div>
);
})}
</div>
<div className="flex justify-between mt-1 text-xs text-slate-600">
<span> ouder</span>
<span>recent </span>
</div>
</div>
)}
{/* Entries list */}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-slate-800/50 rounded-xl h-32 animate-pulse" />
))}
</div>
) : entries.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<div className="text-5xl mb-3">🛌</div>
<p className="text-lg font-medium text-slate-400">Nog niks gelogd</p>
<p className="text-sm mt-1">Ga naar Loggen om je eerste nacht toe te voegen.</p>
</div>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<SleepCard key={entry.id} entry={entry} onDelete={handleDelete} />
))}
</div>
)}
</div>
);
}

29
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { Header } from "@/components/Header";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "SlaapKampioen 💤",
description: "Wie slaapt het meeste?",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body className={`${inter.className} bg-slate-950 text-slate-100 min-h-screen`}>
<Providers>
<Header />
<main className="max-w-2xl mx-auto px-4 py-6">{children}</main>
</Providers>
</body>
</html>
);
}

15
src/app/log/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { SleepForm } from "@/components/SleepForm";
export default function LogPage() {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-100"> Slaap loggen</h1>
<p className="text-slate-500 text-sm mt-1">
Vul je slaapdata in. Fases zijn optioneel niet iedereen heeft een smartwatch.
</p>
</div>
<SleepForm />
</div>
);
}

27
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import { signIn } from "next-auth/react";
export default function LoginPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4">
<div className="text-7xl mb-6">💤</div>
<h1 className="text-3xl font-bold text-slate-100 mb-2">SlaapKampioen</h1>
<p className="text-slate-400 mb-8 max-w-sm">
Log elke nacht je slaap en zie wie het meeste slaapt in jouw vriendengroep.
</p>
<button
onClick={() => signIn("discord")}
className="flex items-center gap-3 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 rounded-xl font-semibold text-white transition-colors text-lg"
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.043.033.057a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
Inloggen met Discord
</button>
<p className="text-slate-600 text-sm mt-6">
Alleen toegankelijk voor uitgenodigde vrienden.
</p>
</div>
);
}

13
src/app/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Leaderboard } from "@/components/Leaderboard";
export default function HomePage() {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-100">🏆 Klassement</h1>
<p className="text-slate-500 text-sm mt-1">Wie slaapt het meeste onder de vrienden?</p>
</div>
<Leaderboard />
</div>
);
}

7
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

78
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
export function Header() {
const { data: session } = useSession();
const pathname = usePathname();
const nav = [
{ href: "/", label: "🏆 Klassement" },
{ href: "/log", label: " Loggen" },
{ href: "/history", label: "📅 Geschiedenis" },
];
return (
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
{/* Logo */}
<Link href="/" className="text-lg font-bold text-slate-100 shrink-0">
💤 SlaapKampioen
</Link>
{/* Nav */}
<nav className="flex gap-1 overflow-x-auto">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-1.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
pathname === item.href
? "bg-indigo-600 text-white"
: "text-slate-400 hover:text-slate-100 hover:bg-slate-800"
}`}
>
{item.label}
</Link>
))}
</nav>
{/* User */}
<div className="shrink-0">
{session ? (
<div className="flex items-center gap-2">
{session.user?.image && (
<Image
src={session.user.image}
alt={session.user.name ?? "avatar"}
width={32}
height={32}
className="rounded-full ring-2 ring-indigo-500"
/>
)}
<button
onClick={() => signOut()}
className="text-xs text-slate-400 hover:text-slate-100 transition-colors"
>
Afmelden
</button>
</div>
) : (
<button
onClick={() => signIn("discord")}
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.043.033.057a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
Discord
</button>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { formatDuration, getSleepQuality } from "@/lib/utils";
import { SleepPhaseBar } from "./SleepPhaseBar";
interface LeaderboardEntry {
id: string;
name: string;
image: string | null;
displayMinutes: number;
isAverage: boolean;
avgDeep: number | null;
avgLight: number | null;
avgRem: number | null;
entryCount: number;
lastEntry: string | null;
}
const PERIODS = [
{ value: "night", label: "Gisternacht" },
{ value: "week", label: "Deze week" },
{ value: "month", label: "Deze maand" },
{ value: "year", label: "Dit jaar" },
];
const MEDALS = ["🥇", "🥈", "🥉"];
export function Leaderboard() {
const [period, setPeriod] = useState("week");
const [data, setData] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/leaderboard?period=${period}`)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [period]);
return (
<div>
{/* Period tabs — scrollable op mobiel */}
<div className="flex gap-2 mb-6 overflow-x-auto pb-1">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
period === p.value
? "bg-indigo-600 text-white"
: "bg-slate-800 text-slate-400 hover:text-slate-100 hover:bg-slate-700"
}`}
>
{p.label}
</button>
))}
</div>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-slate-800/50 rounded-xl h-24 animate-pulse" />
))}
</div>
) : data.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<div className="text-5xl mb-3">😴</div>
<p className="text-lg font-medium text-slate-400">Niemand gelogd</p>
<p className="text-sm mt-1">
{period === "night"
? "Niemand heeft gisternacht gelogd."
: "Nog geen entries in deze periode."}
</p>
</div>
) : (
<div className="space-y-3">
{data.map((entry, index) => {
const quality = getSleepQuality(entry.displayMinutes);
const isTop3 = index < 3;
return (
<div
key={entry.id}
className={`rounded-xl p-4 border transition-all ${
index === 0
? "bg-gradient-to-r from-yellow-500/10 to-amber-500/5 border-yellow-500/30"
: index === 1
? "bg-gradient-to-r from-slate-400/10 to-slate-400/5 border-slate-400/30"
: index === 2
? "bg-gradient-to-r from-orange-700/10 to-orange-700/5 border-orange-700/30"
: "bg-slate-800/50 border-slate-700/50"
}`}
>
<div className="flex items-center gap-3">
{/* Rank */}
<div className="text-2xl w-8 text-center shrink-0">
{isTop3
? MEDALS[index]
: <span className="text-base text-slate-500">#{index + 1}</span>}
</div>
{/* Avatar */}
<div className="shrink-0">
{entry.image ? (
<Image
src={entry.image}
alt={entry.name}
width={44}
height={44}
className="rounded-full"
/>
) : (
<div className="w-11 h-11 rounded-full bg-indigo-700 flex items-center justify-center text-lg font-bold">
{entry.name.charAt(0).toUpperCase()}
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-slate-100 truncate">{entry.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${quality.bg}`}>
{quality.label}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5 text-sm flex-wrap">
<span className="font-mono font-bold text-indigo-300 text-base">
{formatDuration(entry.displayMinutes)}
</span>
<span className="text-slate-500">
{entry.isAverage
? `gem. · ${entry.entryCount} nachten`
: "gelogd"}
</span>
</div>
{/* Mini fase-balk */}
{(entry.avgDeep || entry.avgLight || entry.avgRem) && (
<SleepPhaseBar
totalMinutes={entry.displayMinutes}
deepMinutes={entry.avgDeep}
lightMinutes={entry.avgLight}
remMinutes={entry.avgRem}
showLabels={false}
/>
)}
</div>
{/* Grote uren */}
<div className="shrink-0 text-right">
<div className="text-3xl font-bold text-slate-100">
{(entry.displayMinutes / 60).toFixed(1)}
</div>
<div className="text-xs text-slate-500">uur</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { formatDuration, formatDate, getSleepQuality } from "@/lib/utils";
import { SleepPhaseBar } from "./SleepPhaseBar";
interface SleepEntry {
id: string;
date: string;
bedtime: string | null;
wakeTime: string | null;
totalMinutes: number;
deepMinutes: number | null;
lightMinutes: number | null;
remMinutes: number | null;
awakeMinutes: number | null;
awakeCount: number | null;
notes: string | null;
}
interface SleepCardProps {
entry: SleepEntry;
onDelete: (id: string) => void;
}
function formatTime(dt: string | null): string | null {
if (!dt) return null;
return new Date(dt).toLocaleTimeString("nl-BE", { hour: "2-digit", minute: "2-digit" });
}
export function SleepCard({ entry, onDelete }: SleepCardProps) {
const quality = getSleepQuality(entry.totalMinutes);
async function handleDelete() {
if (!confirm("Wil je dit item verwijderen?")) return;
const res = await fetch(`/api/sleep/${entry.id}`, { method: "DELETE" });
if (res.ok) onDelete(entry.id);
}
return (
<div className="bg-slate-800/60 border border-slate-700/50 rounded-xl p-4">
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold text-slate-200">{formatDate(entry.date)}</div>
{(entry.bedtime || entry.wakeTime) && (
<div className="text-xs text-slate-500 mt-0.5">
{formatTime(entry.bedtime) ?? "?"} {formatTime(entry.wakeTime) ?? "?"}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${quality.bg}`}>
{quality.label}
</span>
<button
onClick={handleDelete}
className="text-slate-600 hover:text-red-400 transition-colors text-sm"
title="Verwijderen"
>
</button>
</div>
</div>
{/* Duration */}
<div className="mt-2 font-bold text-indigo-300 text-xl">
{formatDuration(entry.totalMinutes)}
</div>
{/* Phase breakdown */}
<SleepPhaseBar
totalMinutes={entry.totalMinutes}
deepMinutes={entry.deepMinutes}
lightMinutes={entry.lightMinutes}
remMinutes={entry.remMinutes}
awakeMinutes={entry.awakeMinutes}
awakeCount={entry.awakeCount}
showLabels={true}
/>
{/* Notes */}
{entry.notes && (
<p className="mt-3 text-sm text-slate-400 italic border-t border-slate-700/50 pt-3">
💬 {entry.notes}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,328 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { calculateTotalFromTimes, formatDuration } from "@/lib/utils";
const YESTERDAY = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const TODAY = new Date().toISOString().slice(0, 10);
function fromMinutes(total: number) {
return { h: String(Math.floor(total / 60)), m: String(total % 60) };
}
function toMinutes(h: string, m: string): number | null {
if (!h && !m) return null;
return (parseInt(h) || 0) * 60 + (parseInt(m) || 0);
}
interface PhaseInputProps {
label: string;
color: string;
hKey: string;
mKey: string;
values: Record<string, string>;
onChange: (k: string, v: string) => void;
}
function PhaseInput({ label, color, hKey, mKey, values, onChange }: PhaseInputProps) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 w-36 shrink-0">
<span className={`w-2.5 h-2.5 rounded-sm shrink-0 ${color}`} />
<span className="text-sm text-slate-300">{label}</span>
</div>
<div className="flex gap-2 flex-1">
<input
type="number" min={0} max={23} placeholder="u"
value={values[hKey]}
onChange={(e) => onChange(hKey, e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
<input
type="number" min={0} max={59} placeholder="min"
value={values[mKey]}
onChange={(e) => onChange(mKey, e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
</div>
</div>
);
}
export function SleepForm() {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [date, setDate] = useState(YESTERDAY);
const [bedtime, setBedtime] = useState("");
const [wakeTime, setWakeTime] = useState("");
const [calcTotal, setCalcTotal] = useState<number | null>(null);
const [manualH, setManualH] = useState("");
const [manualM, setManualM] = useState("");
const [useManual, setUseManual] = useState(false);
const [phases, setPhases] = useState({
deepH: "", deepM: "",
lightH: "", lightM: "",
remH: "", remM: "",
awakeH: "", awakeM: "",
awakeCount: "",
});
const [notes, setNotes] = useState("");
useEffect(() => {
const total = calculateTotalFromTimes(bedtime, wakeTime);
setCalcTotal(total);
if (total !== null && !useManual) {
const { h, m } = fromMinutes(total);
setManualH(h);
setManualM(m);
}
}, [bedtime, wakeTime, useManual]);
const totalMinutes: number | null = useManual
? toMinutes(manualH, manualM)
: calcTotal;
function handlePhase(k: string, v: string) {
setPhases((p) => ({ ...p, [k]: v }));
}
async function handleSubmit() {
setError("");
if (!date) { setError("Kies een datum."); return; }
if (!totalMinutes || totalMinutes < 1) {
setError("Vul je slaaptijd in (bedtijd + wektijd, of handmatig).");
return;
}
setSaving(true);
try {
const res = await fetch("/api/sleep", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date,
bedtime: bedtime || undefined,
wakeTime: wakeTime || undefined,
totalMinutes,
deepMinutes: toMinutes(phases.deepH, phases.deepM),
lightMinutes: toMinutes(phases.lightH, phases.lightM),
remMinutes: toMinutes(phases.remH, phases.remM),
awakeMinutes: toMinutes(phases.awakeH, phases.awakeM),
awakeCount: phases.awakeCount ? parseInt(phases.awakeCount) : undefined,
notes: notes || undefined,
}),
});
if (!res.ok) {
const d = await res.json();
setError(d.error ?? "Opslaan mislukt.");
} else {
setSuccess(true);
setTimeout(() => router.push("/history"), 1200);
}
} catch {
setError("Netwerkfout, probeer opnieuw.");
} finally {
setSaving(false);
}
}
if (success) {
return (
<div className="text-center py-16">
<div className="text-5xl mb-3"></div>
<p className="text-lg font-semibold text-green-400">Opgeslagen!</p>
<p className="text-sm text-slate-500 mt-1">Je wordt doorgestuurd</p>
</div>
);
}
return (
<div className="space-y-4">
{/* ── Datum ── */}
<div className="bg-slate-800/60 rounded-xl p-4 space-y-3">
<p className="text-sm font-semibold text-slate-200">📅 Datum van de nacht</p>
<div className="flex gap-2">
<button
onClick={() => setDate(YESTERDAY)}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${
date === YESTERDAY
? "bg-indigo-600 text-white"
: "bg-slate-700 text-slate-400 hover:text-slate-100"
}`}
>
Gisternacht
</button>
<button
onClick={() => setDate(TODAY)}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${
date === TODAY
? "bg-indigo-600 text-white"
: "bg-slate-700 text-slate-400 hover:text-slate-100"
}`}
>
Vannacht
</button>
</div>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* ── Bedtijd & wektijd ── */}
<div className="bg-slate-800/60 rounded-xl p-4 space-y-3">
<p className="text-sm font-semibold text-slate-200"> Slaaptijd & wektijd</p>
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="text-xs text-slate-500 mb-1 block">Gaan slapen</label>
<input
type="time"
value={bedtime}
onChange={(e) => { setBedtime(e.target.value); setUseManual(false); }}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2.5 text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="text-slate-600 pb-2.5 text-lg"></div>
<div className="flex-1">
<label className="text-xs text-slate-500 mb-1 block">Wakker worden</label>
<input
type="time"
value={wakeTime}
onChange={(e) => { setWakeTime(e.target.value); setUseManual(false); }}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2.5 text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{/* Auto-berekend totaal */}
{calcTotal !== null && !useManual ? (
<div className="flex items-center gap-2 px-3 py-2.5 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
<span className="text-indigo-300 text-sm">
💤 Totale slaap: <strong>{formatDuration(calcTotal)}</strong>
</span>
<span className="text-xs text-slate-600 ml-auto">automatisch berekend</span>
</div>
) : (
!useManual && (
<p className="text-xs text-slate-600 text-center py-1">
Vul beide tijden in totaal wordt automatisch berekend
</p>
)
)}
{/* Handmatige invoer toggle */}
<button
onClick={() => setUseManual((v) => !v)}
className="text-xs text-slate-500 hover:text-slate-300 underline underline-offset-2 transition-colors"
>
{useManual
? "← Terug naar automatisch"
: "Geen exacte tijden? Vul totaal handmatig in"}
</button>
{useManual && (
<div className="space-y-1.5">
<label className="text-xs text-slate-400 block">Totale slaap (handmatig)</label>
<div className="flex gap-2">
<input
type="number" min={0} max={23} placeholder="uren"
value={manualH}
onChange={(e) => setManualH(e.target.value)}
className="flex-1 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
<input
type="number" min={0} max={59} placeholder="minuten"
value={manualM}
onChange={(e) => setManualM(e.target.value)}
className="flex-1 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
</div>
</div>
)}
</div>
{/* ── Slaapfases ── */}
<div className="bg-slate-800/60 rounded-xl p-4 space-y-3">
<div>
<p className="text-sm font-semibold text-slate-200">🧠 Slaapfases</p>
<p className="text-xs text-slate-500 mt-0.5">
Optioneel alleen invullen als je smartwatch dit bijhoudt
</p>
</div>
<PhaseInput label="Diepe slaap" color="bg-violet-600" hKey="deepH" mKey="deepM" values={phases} onChange={handlePhase} />
<PhaseInput label="Lichte slaap" color="bg-indigo-400" hKey="lightH" mKey="lightM" values={phases} onChange={handlePhase} />
<PhaseInput label="REM slaap" color="bg-sky-400" hKey="remH" mKey="remM" values={phases} onChange={handlePhase} />
{/* Wakker momenten */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 w-36 shrink-0">
<span className="w-2.5 h-2.5 rounded-sm shrink-0 bg-amber-400" />
<span className="text-sm text-slate-300">Wakker</span>
</div>
<div className="flex gap-2 flex-1">
<input
type="number" min={0} max={20} placeholder="× keer"
value={phases.awakeCount}
onChange={(e) => handlePhase("awakeCount", e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
<input
type="number" min={0} max={23} placeholder="u"
value={phases.awakeH}
onChange={(e) => handlePhase("awakeH", e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
<input
type="number" min={0} max={59} placeholder="min"
value={phases.awakeM}
onChange={(e) => handlePhase("awakeM", e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500"
/>
</div>
</div>
</div>
{/* ── Notities ── */}
<div className="bg-slate-800/60 rounded-xl p-4">
<label className="text-sm font-semibold text-slate-200 mb-2 block">
💬 Notities{" "}
<span className="text-slate-600 font-normal text-xs">(optioneel)</span>
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Slecht geslapen door het lawaai van de buren…"
rows={2}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-slate-500 resize-none"
/>
</div>
{/* Fout */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 rounded-xl px-4 py-3 text-sm">
{error}
</div>
)}
{/* Submit */}
<button
onClick={handleSubmit}
disabled={saving || !totalMinutes}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed rounded-xl font-semibold text-white transition-colors"
>
{saving ? "Opslaan…" : "💾 Slaap opslaan"}
</button>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { getPhaseQuality } from "@/lib/utils";
interface SleepPhaseBarProps {
totalMinutes: number;
deepMinutes?: number | null;
lightMinutes?: number | null;
remMinutes?: number | null;
awakeMinutes?: number | null;
awakeCount?: number | null;
showLabels?: boolean;
}
export function SleepPhaseBar({
totalMinutes,
deepMinutes,
lightMinutes,
remMinutes,
awakeMinutes,
awakeCount,
showLabels = true,
}: SleepPhaseBarProps) {
const hasPhases = deepMinutes || lightMinutes || remMinutes;
function fmt(min: number) {
const h = Math.floor(min / 60);
const m = min % 60;
return h > 0 ? `${h}u ${m.toString().padStart(2, "0")}min` : `${m}min`;
}
function pct(min: number) {
return Math.max(2, Math.round((min / totalMinutes) * 100));
}
if (!hasPhases) {
return (
<div className="mt-2">
<div className="h-3 rounded-full bg-indigo-500/60 w-full" title={`${fmt(totalMinutes)} totaal`} />
</div>
);
}
const phases = [
{ key: "deep", label: "Diepe slaap", value: deepMinutes!, color: "bg-violet-600", type: "deep" as const },
{ key: "light", label: "Lichte slaap", value: lightMinutes!, color: "bg-indigo-400", type: "light" as const },
{ key: "rem", label: "REM", value: remMinutes!, color: "bg-sky-400", type: "rem" as const },
{ key: "awake", label: "Wakker", value: awakeMinutes!, color: "bg-amber-400", type: "awake" as const },
].filter((p) => p.value);
return (
<div className="mt-3 space-y-2">
{/* Visual bar */}
<div className="flex h-3 rounded-full overflow-hidden gap-px">
{phases.map((phase) => (
<div
key={phase.key}
className={`${phase.color} transition-all`}
style={{ width: `${pct(phase.value)}%` }}
title={`${phase.label}: ${fmt(phase.value)}`}
/>
))}
</div>
{/* Labels */}
{showLabels && (
<div className="space-y-1">
{phases.map((phase) => {
const quality = getPhaseQuality(phase.value, totalMinutes, phase.type);
return (
<div key={phase.key} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${phase.color}`} />
<span className="text-slate-400">{phase.label}</span>
<span className="text-slate-300 font-medium">{fmt(phase.value)}</span>
<span className="text-slate-500">
| {Math.round((phase.value / totalMinutes) * 100)}%
</span>
</div>
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${quality.className}`}>
{quality.label}
</span>
</div>
);
})}
{awakeCount != null && (
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<span>🔔 {awakeCount}× wakker geworden</span>
</div>
)}
</div>
)}
</div>
);
}

25
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "./prisma";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
}),
],
callbacks: {
session({ session, user }) {
if (session.user) {
session.user.id = user.id;
}
return session;
},
},
pages: {
signIn: "/login",
},
};

13
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

67
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,67 @@
export function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}u ${m.toString().padStart(2, "0")}min`;
}
export function minutesToHoursDecimal(minutes: number): number {
return Math.round((minutes / 60) * 10) / 10;
}
export function getSleepQuality(minutes: number): {
label: string;
color: string;
bg: string;
} {
if (minutes < 300) return { label: "Heel kort", color: "text-red-400", bg: "bg-red-500/20 text-red-400" };
if (minutes < 360) return { label: "Kort", color: "text-orange-400", bg: "bg-orange-500/20 text-orange-400" };
if (minutes < 420) return { label: "Matig", color: "text-yellow-400", bg: "bg-yellow-500/20 text-yellow-400" };
if (minutes <= 540) return { label: "Goed", color: "text-green-400", bg: "bg-green-500/20 text-green-400" };
return { label: "Lang", color: "text-blue-400", bg: "bg-blue-500/20 text-blue-400" };
}
export function getPhaseQuality(
phaseMinutes: number,
totalMinutes: number,
type: "deep" | "light" | "rem" | "awake"
): { label: string; className: string } {
const pct = (phaseMinutes / totalMinutes) * 100;
if (type === "deep") {
if (pct >= 13 && pct <= 25) return { label: "Normaal", className: "bg-green-500/20 text-green-400" };
if (pct > 25) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" };
return { label: "Kort", className: "bg-red-500/20 text-red-400" };
}
if (type === "light") {
if (pct >= 45 && pct <= 65) return { label: "Normaal", className: "bg-green-500/20 text-green-400" };
if (pct > 65) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" };
return { label: "Kort", className: "bg-red-500/20 text-red-400" };
}
if (type === "rem") {
if (pct >= 15 && pct <= 25) return { label: "Normaal", className: "bg-green-500/20 text-green-400" };
if (pct > 25) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" };
return { label: "Kort", className: "bg-red-500/20 text-red-400" };
}
// awake
if (pct <= 5) return { label: "Normaal", className: "bg-green-500/20 text-green-400" };
if (pct <= 10) return { label: "Matig", className: "bg-yellow-500/20 text-yellow-400" };
return { label: "Veel", className: "bg-red-500/20 text-red-400" };
}
export function formatDate(date: string | Date): string {
return new Date(date).toLocaleDateString("nl-BE", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
}
export function calculateTotalFromTimes(bedtime: string, wakeTime: string): number | null {
if (!bedtime || !wakeTime) return null;
const [bh, bm] = bedtime.split(":").map(Number);
const [wh, wm] = wakeTime.split(":").map(Number);
let total = wh * 60 + wm - (bh * 60 + bm);
if (total <= 0) total += 24 * 60; // crosses midnight
return total;
}

11
src/middleware.ts Normal file
View File

@@ -0,0 +1,11 @@
import { withAuth } from "next-auth/middleware";
export default withAuth({
pages: {
signIn: "/login",
},
});
export const config = {
matcher: ["/", "/log", "/history"],
};

9
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}