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

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# ============================================================
# SlaapKampioen — omgevingsvariabelen
# Kopieer dit bestand naar .env en vul de waarden in.
# Commit NOOIT je .env naar git!
# ============================================================
# --- Database ---
# Automatisch gebruikt door docker-compose
POSTGRES_PASSWORD=verander_dit_wachtwoord
# --- NextAuth ---
# Jouw publieke URL (incl. protocol, geen trailing slash)
NEXTAUTH_URL=https://slaap.jouwdomein.be
# Willekeurig geheim — genereer met: openssl rand -base64 32
NEXTAUTH_SECRET=
# --- Discord OAuth ---
# Maak een app aan op https://discord.com/developers/applications
# Redirect URI instellen op: https://slaap.jouwdomein.be/api/auth/callback/discord
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

View File

@@ -0,0 +1,24 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/slaapkampioen
git pull
docker compose build --no-cache
docker compose up -d
docker compose logs --tail=20 app

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Next.js
.next/
out/
build/
# Env files — NOOIT committen!
.env
.env.local
.env.*.local
# Prisma generated
prisma/migrations/
# Misc
.DS_Store
*.pem
.vercel
npm-debug.log*
yarn-debug.log*
yarn-error.log*

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# ---- Dependencies ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ---- Builder ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# ---- Runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy prisma for migrations
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Entrypoint script
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["./docker-entrypoint.sh"]

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
services:
app:
build: .
container_name: slaapkampioen
restart: unless-stopped
environment:
DATABASE_URL: postgresql://sleep:${POSTGRES_PASSWORD}@db:5432/sleep
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET}
ports:
- "127.0.0.1:3010:3000" # alleen lokaal bereikbaar, nginx proxied dit
depends_on:
db:
condition: service_healthy
networks:
- internal
db:
image: postgres:16-alpine
container_name: slaapkampioen-db
restart: unless-stopped
environment:
POSTGRES_DB: sleep
POSTGRES_USER: sleep
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sleep -d sleep"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
networks:
internal:
driver: bridge

8
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "🔄 Running database migrations..."
npx prisma migrate deploy
echo "🚀 Starting SlaapKampioen..."
exec node server.js

14
next.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.discordapp.com",
},
],
},
};
export default nextConfig;

37
nginx.example.conf Normal file
View File

@@ -0,0 +1,37 @@
# nginx reverse proxy config voor SlaapKampioen
# Sla op als /etc/nginx/sites-available/slaapkampioen
# en symlink naar /etc/nginx/sites-enabled/
server {
listen 80;
server_name slaap.jouwdomein.be;
# Redirect HTTP → HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name slaap.jouwdomein.be;
# SSL (bv. via certbot / Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/slaap.jouwdomein.be/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/slaap.jouwdomein.be/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Proxy naar Docker container (127.0.0.1:3010 zoals in docker-compose.yml)
location / {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "sleep-tracker",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:migrate": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:generate": "prisma generate"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.16.0",
"next": "^14.2.5",
"next-auth": "^4.24.7",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"prisma": "^5.16.0",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

89
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,89 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- NextAuth required models ---
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// --- App models ---
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
sleepEntries SleepEntry[]
createdAt DateTime @default(now())
}
model SleepEntry {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// The date of the night (e.g. 2024-01-15 = night of 14→15 jan)
date DateTime @db.Date
// Optional: exact times
bedtime DateTime?
wakeTime DateTime?
// Required: total sleep
totalMinutes Int
// Optional: phase breakdown (in minutes)
deepMinutes Int?
lightMinutes Int?
remMinutes Int?
awakeMinutes Int?
awakeCount Int?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, date])
}

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"];
}
}

14
tailwind.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}