diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..60d1df2 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..c3a9b54 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c080985 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82d3eb0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6fbf349 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..2b2aef2 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "πŸ”„ Running database migrations..." +npx prisma migrate deploy + +echo "πŸš€ Starting SlaapKampioen..." +exec node server.js diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..4a2021b --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.discordapp.com", + }, + ], + }, +}; + +export default nextConfig; diff --git a/nginx.example.conf b/nginx.example.conf new file mode 100644 index 0000000..85e2008 --- /dev/null +++ b/nginx.example.conf @@ -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; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a10ec2 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..50c5c39 --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..9cd7923 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts new file mode 100644 index 0000000..70c012f --- /dev/null +++ b/src/app/api/leaderboard/route.ts @@ -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); +} diff --git a/src/app/api/sleep/[id]/route.ts b/src/app/api/sleep/[id]/route.ts new file mode 100644 index 0000000..3ad4a86 --- /dev/null +++ b/src/app/api/sleep/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/sleep/route.ts b/src/app/api/sleep/route.ts new file mode 100644 index 0000000..689dccd --- /dev/null +++ b/src/app/api/sleep/route.ts @@ -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 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx new file mode 100644 index 0000000..36be5af --- /dev/null +++ b/src/app/history/page.tsx @@ -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([]); + 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 ( +
+
+

πŸ“… Mijn geschiedenis

+

Jouw slaaplogboek

+
+ + {/* Stats summary */} + {entries.length > 0 && ( +
+
+
{entries.length}
+
nachten
+
+
+
{avg ? formatDuration(avg) : "–"}
+
gemiddeld
+
+
+
{best ? formatDuration(best) : "–"}
+
beste nacht
+
+
+ )} + + {/* Mini bar chart */} + {entries.length > 0 && ( +
+

Laatste 14 nachten

+
+ {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 ( +
+
+
+ ); + })} +
+
+ ← ouder + recent β†’ +
+
+ )} + + {/* Entries list */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
+
πŸ›Œ
+

Nog niks gelogd

+

Ga naar βž• Loggen om je eerste nacht toe te voegen.

+
+ ) : ( +
+ {entries.map((entry) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..211620b --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + +
+
{children}
+ + + + ); +} diff --git a/src/app/log/page.tsx b/src/app/log/page.tsx new file mode 100644 index 0000000..e37e9c4 --- /dev/null +++ b/src/app/log/page.tsx @@ -0,0 +1,15 @@ +import { SleepForm } from "@/components/SleepForm"; + +export default function LogPage() { + return ( +
+
+

βž• Slaap loggen

+

+ Vul je slaapdata in. Fases zijn optioneel β€” niet iedereen heeft een smartwatch. +

+
+ +
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..c86e49f --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { signIn } from "next-auth/react"; + +export default function LoginPage() { + return ( +
+
πŸ’€
+

SlaapKampioen

+

+ Log elke nacht je slaap en zie wie het meeste slaapt in jouw vriendengroep. +

+ +

+ Alleen toegankelijk voor uitgenodigde vrienden. +

+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..7a416dd --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,13 @@ +import { Leaderboard } from "@/components/Leaderboard"; + +export default function HomePage() { + return ( +
+
+

πŸ† Klassement

+

Wie slaapt het meeste onder de vrienden?

+
+ +
+ ); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..f4cd92d --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..0ebf162 --- /dev/null +++ b/src/components/Header.tsx @@ -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 ( +
+
+ {/* Logo */} + + πŸ’€ SlaapKampioen + + + {/* Nav */} + + + {/* User */} +
+ {session ? ( +
+ {session.user?.image && ( + {session.user.name + )} + +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/Leaderboard.tsx b/src/components/Leaderboard.tsx new file mode 100644 index 0000000..38fcdaa --- /dev/null +++ b/src/components/Leaderboard.tsx @@ -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([]); + 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 ( +
+ {/* Period tabs β€” scrollable op mobiel */} +
+ {PERIODS.map((p) => ( + + ))} +
+ + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : data.length === 0 ? ( +
+
😴
+

Niemand gelogd

+

+ {period === "night" + ? "Niemand heeft gisternacht gelogd." + : "Nog geen entries in deze periode."} +

+
+ ) : ( +
+ {data.map((entry, index) => { + const quality = getSleepQuality(entry.displayMinutes); + const isTop3 = index < 3; + + return ( +
+
+ {/* Rank */} +
+ {isTop3 + ? MEDALS[index] + : #{index + 1}} +
+ + {/* Avatar */} +
+ {entry.image ? ( + {entry.name} + ) : ( +
+ {entry.name.charAt(0).toUpperCase()} +
+ )} +
+ + {/* Info */} +
+
+ {entry.name} + + {quality.label} + +
+
+ + {formatDuration(entry.displayMinutes)} + + + {entry.isAverage + ? `gem. Β· ${entry.entryCount} nachten` + : "gelogd"} + +
+ + {/* Mini fase-balk */} + {(entry.avgDeep || entry.avgLight || entry.avgRem) && ( + + )} +
+ + {/* Grote uren */} +
+
+ {(entry.displayMinutes / 60).toFixed(1)} +
+
uur
+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/SleepCard.tsx b/src/components/SleepCard.tsx new file mode 100644 index 0000000..941c857 --- /dev/null +++ b/src/components/SleepCard.tsx @@ -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 ( +
+ {/* Header row */} +
+
+
{formatDate(entry.date)}
+ {(entry.bedtime || entry.wakeTime) && ( +
+ {formatTime(entry.bedtime) ?? "?"} β†’ {formatTime(entry.wakeTime) ?? "?"} +
+ )} +
+
+ + {quality.label} + + +
+
+ + {/* Duration */} +
+ {formatDuration(entry.totalMinutes)} +
+ + {/* Phase breakdown */} + + + {/* Notes */} + {entry.notes && ( +

+ πŸ’¬ {entry.notes} +

+ )} +
+ ); +} diff --git a/src/components/SleepForm.tsx b/src/components/SleepForm.tsx new file mode 100644 index 0000000..5285bf1 --- /dev/null +++ b/src/components/SleepForm.tsx @@ -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; + onChange: (k: string, v: string) => void; +} + +function PhaseInput({ label, color, hKey, mKey, values, onChange }: PhaseInputProps) { + return ( +
+
+ + {label} +
+
+ 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" + /> + 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" + /> +
+
+ ); +} + +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(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 ( +
+
βœ…
+

Opgeslagen!

+

Je wordt doorgestuurd…

+
+ ); + } + + return ( +
+ + {/* ── Datum ── */} +
+

πŸ“… Datum van de nacht

+
+ + +
+ 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" + /> +
+ + {/* ── Bedtijd & wektijd ── */} +
+

⏰ Slaaptijd & wektijd

+ +
+
+ + { 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" + /> +
+
β†’
+
+ + { 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" + /> +
+
+ + {/* Auto-berekend totaal */} + {calcTotal !== null && !useManual ? ( +
+ + πŸ’€ Totale slaap: {formatDuration(calcTotal)} + + automatisch berekend +
+ ) : ( + !useManual && ( +

+ Vul beide tijden in β†’ totaal wordt automatisch berekend +

+ ) + )} + + {/* Handmatige invoer toggle */} + + + {useManual && ( +
+ +
+ 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" + /> + 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" + /> +
+
+ )} +
+ + {/* ── Slaapfases ── */} +
+
+

🧠 Slaapfases

+

+ Optioneel β€” alleen invullen als je smartwatch dit bijhoudt +

+
+ + + + + + {/* Wakker momenten */} +
+
+ + Wakker +
+
+ 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" + /> + 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" + /> + 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" + /> +
+
+
+ + {/* ── Notities ── */} +
+ +