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

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

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

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

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

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

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

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