feat: add sleep tracking features with leaderboard and user authentication
Some checks failed
Deploy / deploy (push) Failing after 50s
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:
22
.env.example
Normal file
22
.env.example
Normal 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=
|
||||
24
.gitea/workflows/deploy.yml
Normal file
24
.gitea/workflows/deploy.yml
Normal 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
25
.gitignore
vendored
Normal 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
42
Dockerfile
Normal 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
43
docker-compose.yml
Normal 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
8
docker-entrypoint.sh
Normal 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
14
next.config.mjs
Normal 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
37
nginx.example.conf
Normal 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
32
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
89
prisma/schema.prisma
Normal file
89
prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
5
src/app/api/auth/[...nextauth]/route.ts
Normal file
5
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
84
src/app/api/leaderboard/route.ts
Normal file
84
src/app/api/leaderboard/route.ts
Normal 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);
|
||||
}
|
||||
22
src/app/api/sleep/[id]/route.ts
Normal file
22
src/app/api/sleep/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
87
src/app/api/sleep/route.ts
Normal file
87
src/app/api/sleep/route.ts
Normal 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
3
src/app/globals.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
121
src/app/history/page.tsx
Normal file
121
src/app/history/page.tsx
Normal 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
29
src/app/layout.tsx
Normal 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
15
src/app/log/page.tsx
Normal 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
27
src/app/login/page.tsx
Normal 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
13
src/app/page.tsx
Normal 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
7
src/app/providers.tsx
Normal 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
78
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/components/Leaderboard.tsx
Normal file
167
src/components/Leaderboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/SleepCard.tsx
Normal file
89
src/components/SleepCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
328
src/components/SleepForm.tsx
Normal file
328
src/components/SleepForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/SleepPhaseBar.tsx
Normal file
93
src/components/SleepPhaseBar.tsx
Normal 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
25
src/lib/auth.ts
Normal 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
13
src/lib/prisma.ts
Normal 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
67
src/lib/utils.ts
Normal 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
11
src/middleware.ts
Normal 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
9
src/types/next-auth.d.ts
vendored
Normal 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
14
tailwind.config.ts
Normal 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
22
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user