feat: initial commit
This commit is contained in:
commit
7d443a004a
76 changed files with 15704 additions and 0 deletions
3
.env
Normal file
3
.env
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://yntzfgnkrwjlnbaxxkkc.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_AVx1DjyUnRtpqGoyHhVR_A_GCtJVXZh
|
||||||
|
NEXT_PUBLIC_SUPABASE_SERVICE_KEY=sb_service_AVx1DjyUnRtpqGoyHhVR_A_GCtJVXZh
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
|
||||||
2
.env.local
Normal file
2
.env.local
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://yntzfgnkrwjlnbaxxkkc.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_AVx1DjyUnRtpqGoyHhVR_A_GCtJVXZh
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.vercel
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
20.9.0
|
||||||
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"sqltools.connections": [
|
||||||
|
{
|
||||||
|
"ssh": "Disabled",
|
||||||
|
"previewLimit": 50,
|
||||||
|
"server": "aws-0-eu-west-1.pooler.supabase.com",
|
||||||
|
"port": 5432,
|
||||||
|
"driver": "PostgreSQL",
|
||||||
|
"name": "supabase im 2",
|
||||||
|
"database": "postgres",
|
||||||
|
"username": "postgres.yntzfgnkrwjlnbaxxkkc",
|
||||||
|
"password": "$fzysXR5Z+&g-4#"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
50
README.md
Normal file
50
README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Inspannings Monitor
|
||||||
|
|
||||||
|
Wellness-first webapp voor individuele gebruikers die hun energie willen plannen, uitvoeren en evalueren.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 16 App Router
|
||||||
|
- React 19
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- Vercel als hostingdoel
|
||||||
|
- Supabase voor database en authenticatie
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `npm run dev`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run start`
|
||||||
|
- `npm run lint`
|
||||||
|
|
||||||
|
## Supabase Auth configuratie
|
||||||
|
|
||||||
|
1. Kopieer `.env.example` naar `.env.local`
|
||||||
|
2. Vul in:
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`
|
||||||
|
3. Zet in Supabase Dashboard aan:
|
||||||
|
- Email/password auth
|
||||||
|
- Self-signup
|
||||||
|
- Email confirmation verplicht
|
||||||
|
4. Voeg redirect URLs toe voor:
|
||||||
|
- `http://localhost:3000/auth/confirm`
|
||||||
|
- je Vercel productie-URL
|
||||||
|
- eventuele preview-URL's die je wilt testen
|
||||||
|
|
||||||
|
## Supabase database migraties
|
||||||
|
|
||||||
|
Voor `ST-102` staat de eerste databasefundering in:
|
||||||
|
|
||||||
|
- `supabase/migrations/20260418_create_profiles_and_user_settings.sql`
|
||||||
|
|
||||||
|
Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je
|
||||||
|
de profile/settings-laag lokaal test.
|
||||||
|
|
||||||
|
## Eerstvolgende bouwstappen
|
||||||
|
|
||||||
|
1. `ST-201` Ochtendcheck-in UI bouwen
|
||||||
|
2. `ST-203` Budgetlogica implementeren
|
||||||
|
3. `ST-301` Activiteitenmodel en planning opzetten
|
||||||
|
4. `ST-105` RLS-policy tests en hardening afronden
|
||||||
101
app/auth-actions.ts
Normal file
101
app/auth-actions.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
buildPathWithQuery,
|
||||||
|
getRequestOrigin,
|
||||||
|
sanitizeNextPath,
|
||||||
|
} from "@/lib/auth/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { hasSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
|
function getString(formData: FormData, key: string) {
|
||||||
|
const value = formData.get(key);
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInAction(formData: FormData) {
|
||||||
|
const next = sanitizeNextPath(getString(formData, "next"));
|
||||||
|
|
||||||
|
if (!hasSupabaseEnv()) {
|
||||||
|
redirect(buildPathWithQuery("/login", { error: "auth-not-configured", next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = getString(formData, "email");
|
||||||
|
const password = getString(formData, "password");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
redirect(buildPathWithQuery("/login", { error: "missing-fields", next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const normalizedMessage = error.message.toLowerCase();
|
||||||
|
const errorParam =
|
||||||
|
error.code === "email_not_confirmed" ||
|
||||||
|
normalizedMessage.includes("email not confirmed")
|
||||||
|
? "email-not-confirmed"
|
||||||
|
: "invalid-credentials";
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/login", { error: errorParam, next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signUpAction(formData: FormData) {
|
||||||
|
const next = sanitizeNextPath(getString(formData, "next"));
|
||||||
|
|
||||||
|
if (!hasSupabaseEnv()) {
|
||||||
|
redirect(
|
||||||
|
buildPathWithQuery("/sign-up", { error: "auth-not-configured", next }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = getString(formData, "email");
|
||||||
|
const password = getString(formData, "password");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
redirect(buildPathWithQuery("/sign-up", { error: "missing-fields", next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const headerStore = await headers();
|
||||||
|
const origin = getRequestOrigin(headerStore);
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
emailRedirectTo: `${origin}/auth/confirm?next=${encodeURIComponent(next)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const normalizedMessage = error.message.toLowerCase();
|
||||||
|
const errorParam =
|
||||||
|
error.code === "over_email_send_rate_limit" ||
|
||||||
|
normalizedMessage.includes("rate limit")
|
||||||
|
? "signup-rate-limited"
|
||||||
|
: "signup-failed";
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/sign-up", { error: errorParam, next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/sign-up", { status: "check-email", next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutAction() {
|
||||||
|
if (hasSupabaseEnv()) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/", { status: "signed-out" }));
|
||||||
|
}
|
||||||
34
app/auth/confirm/route.ts
Normal file
34
app/auth/confirm/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { type EmailOtpType } from "@supabase/supabase-js";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { hasSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tokenHash = searchParams.get("token_hash");
|
||||||
|
const type = searchParams.get("type") as EmailOtpType | null;
|
||||||
|
const next = sanitizeNextPath(searchParams.get("next"));
|
||||||
|
|
||||||
|
if (!hasSupabaseEnv()) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(buildPathWithQuery("/login", { error: "auth-not-configured" }), request.url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenHash && type) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.verifyOtp({
|
||||||
|
type,
|
||||||
|
token_hash: tokenHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return NextResponse.redirect(new URL(next, request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(buildPathWithQuery("/login", { error: "verification-failed" }), request.url),
|
||||||
|
);
|
||||||
|
}
|
||||||
204
app/dashboard/page.tsx
Normal file
204
app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { signOutAction } from "@/app/auth-actions";
|
||||||
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type DashboardPageProps = {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParamValue(
|
||||||
|
params: Record<string, string | string[] | undefined>,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToggleState(value: boolean, enabledLabel = "Aan", disabledLabel = "Uit") {
|
||||||
|
return value ? enabledLabel : disabledLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReminderTime(value: string | null) {
|
||||||
|
return value ? value.slice(0, 5) : "Nog niet ingesteld";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDashboardNotice(status: string | null) {
|
||||||
|
if (status === "onboarding-completed") {
|
||||||
|
return "Je onboarding is opgeslagen. Je basisinstellingen staan nu klaar.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "onboarding-skipped") {
|
||||||
|
return "Je hebt de onboarding nu overgeslagen. Je kunt hem later alsnog afronden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
||||||
|
if (!authState.isConfigured) {
|
||||||
|
redirect("/login?error=auth-not-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/dashboard"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileBundle = await getProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
if (!profileBundle) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/dashboard"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profile, settings } = profileBundle;
|
||||||
|
const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status"));
|
||||||
|
|
||||||
|
if (!profile.onboardingSeen) {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileTitle = profile.displayName ?? profile.email ?? authState.email ?? "Ingelogde gebruiker";
|
||||||
|
const onboardingState = profile.onboardingCompleted ? "Afgerond" : "Nog niet afgerond";
|
||||||
|
const morningReminderState = settings.morningReminderEnabled
|
||||||
|
? `Aan om ${formatReminderTime(settings.morningReminderTime)}`
|
||||||
|
: "Uit";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||||
|
{notice ? (
|
||||||
|
<div className="rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm leading-7 text-emerald-900">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Protected route
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||||
|
Dashboard placeholder voor release 1
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
|
||||||
|
Je sessie is server-side gevalideerd en het minimale profielbundle is
|
||||||
|
nu beschikbaar. Daarmee staat de fundering voor onboarding, settings
|
||||||
|
en de eerste energieflows klaar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="inline-flex rounded-full border border-black/10 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:-translate-y-0.5 hover:text-slate-950"
|
||||||
|
>
|
||||||
|
Instellingen
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex rounded-full border border-emerald-900/15 bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-5 md:grid-cols-3">
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Auth
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
Cookie-based sessie actief
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Gebruiker-ID `{authState.userId}` is server-side gevalideerd via Supabase SSR-auth.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Profiel
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
{profileTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per
|
||||||
|
gebruiker opgeslagen.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Onboarding
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
{onboardingState}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Nieuwe accounts starten bewust zonder afgeronde onboarding, zodat
|
||||||
|
`ST-103` straks een duidelijke eerste flow kan aansturen.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Instellingen
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
Punten {formatToggleState(settings.showEnergyPoints, "zichtbaar", "verborgen")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Ochtendreminder: {morningReminderState}. Reflectieprompts:{" "}
|
||||||
|
{formatToggleState(settings.reflectionReminderEnabled)}.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!profile.onboardingCompleted ? (
|
||||||
|
<section className="flex flex-col gap-4 rounded-[1.75rem] border border-amber-900/15 bg-amber-50 px-6 py-5 text-sm leading-7 text-amber-950 shadow-[0_12px_40px_rgba(146,64,14,0.08)] sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Je onboarding is nog niet afgerond.</p>
|
||||||
|
<p className="mt-1 max-w-2xl text-amber-900">
|
||||||
|
Je kunt de korte flow later alsnog afronden om je basisinstellingen
|
||||||
|
en eerste voorkeuren vast te leggen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/onboarding"
|
||||||
|
className="inline-flex shrink-0 rounded-full bg-amber-950 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:-translate-y-0.5 hover:bg-amber-900"
|
||||||
|
>
|
||||||
|
Rond onboarding af
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="flex flex-col gap-4 rounded-[1.75rem] border border-emerald-950/10 bg-emerald-950 px-6 py-5 text-sm leading-7 text-emerald-50 shadow-[0_12px_40px_rgba(6,78,59,0.18)] sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Je instellingen kun je nu ook los beheren.</p>
|
||||||
|
<p className="mt-1 max-w-2xl text-emerald-100/85">
|
||||||
|
`ST-104` staat nu klaar als aparte route, zodat je reminders,
|
||||||
|
timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="inline-flex shrink-0 rounded-full bg-white px-5 py-3 text-sm font-semibold text-emerald-950 transition hover:-translate-y-0.5 hover:bg-emerald-50"
|
||||||
|
>
|
||||||
|
Open instellingen
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/globals.css
Normal file
153
app/globals.css
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-display: "Iowan Old Style", "Palatino Linotype", "URW Palladio L",
|
||||||
|
Palatino, Georgia, serif;
|
||||||
|
--font-body: "Inter", "Aptos", "Segoe UI", "Helvetica Neue", Arial,
|
||||||
|
sans-serif;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-body), sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/layout.tsx
Normal file
24
app/layout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Geist } from "next/font/google";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Inspannings Monitor",
|
||||||
|
description:
|
||||||
|
"Wellness-first app voor energieplanning, zelfreflectie en een rustige plan-doe-evalueer flow.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="nl" className={cn("font-sans", geist.variable)}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
app/login/page.tsx
Normal file
96
app/login/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthNotice } from "@/components/auth/auth-notice";
|
||||||
|
import { AuthPanel } from "@/components/auth/auth-panel";
|
||||||
|
import { signInAction } from "@/app/auth-actions";
|
||||||
|
import { getAuthNotice } from "@/lib/auth/messages";
|
||||||
|
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParamValue(
|
||||||
|
params: Record<string, string | string[] | undefined>,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const next = sanitizeNextPath(getParamValue(resolvedSearchParams, "next"));
|
||||||
|
|
||||||
|
if (authState.isAuthenticated) {
|
||||||
|
redirect(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = getAuthNotice(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
|
const signUpHref = buildPathWithQuery("/sign-up", { next });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPanel
|
||||||
|
eyebrow="Inloggen"
|
||||||
|
title="Welkom terug"
|
||||||
|
description="Log in om je dashboard te openen en je wellness-first flow verder op te bouwen."
|
||||||
|
footer={
|
||||||
|
<p>
|
||||||
|
Nog geen account?{" "}
|
||||||
|
<Link href={signUpHref} className="font-semibold text-emerald-900">
|
||||||
|
Maak er een aan
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthNotice notice={notice} />
|
||||||
|
|
||||||
|
{!authState.isConfigured ? (
|
||||||
|
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-4 text-sm leading-7 text-sky-900">
|
||||||
|
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form action={signInAction} className="space-y-4">
|
||||||
|
<input type="hidden" name="next" value={next} />
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
E-mailadres
|
||||||
|
<input
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
Wachtwoord
|
||||||
|
<input
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex w-full items-center justify-center rounded-2xl bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Inloggen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</AuthPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/onboarding/actions.ts
Normal file
39
app/onboarding/actions.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
|
import {
|
||||||
|
completeOnboardingForCurrentUser,
|
||||||
|
markOnboardingSeenForCurrentUser,
|
||||||
|
} from "@/lib/profile/service";
|
||||||
|
import type { OnboardingSubmission } from "@/lib/profile/types";
|
||||||
|
|
||||||
|
function getString(formData: FormData, key: string) {
|
||||||
|
const value = formData.get(key);
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoolean(formData: FormData, key: string) {
|
||||||
|
return formData.get(key) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOnboardingSubmission(formData: FormData): OnboardingSubmission {
|
||||||
|
return {
|
||||||
|
displayName: getString(formData, "displayName") || null,
|
||||||
|
timezone: getString(formData, "timezone"),
|
||||||
|
morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"),
|
||||||
|
morningReminderTime: getString(formData, "morningReminderTime") || null,
|
||||||
|
reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"),
|
||||||
|
showEnergyPoints: getBoolean(formData, "showEnergyPoints"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeOnboardingAction(formData: FormData) {
|
||||||
|
await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData));
|
||||||
|
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-completed" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipOnboardingAction() {
|
||||||
|
await markOnboardingSeenForCurrentUser();
|
||||||
|
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" }));
|
||||||
|
}
|
||||||
37
app/onboarding/page.tsx
Normal file
37
app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||||
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function OnboardingPage() {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
|
||||||
|
if (!authState.isConfigured) {
|
||||||
|
redirect("/login?error=auth-not-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/onboarding"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileBundle = await getProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
if (!profileBundle) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/onboarding"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileBundle.profile.onboardingCompleted) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||||
|
<OnboardingFlow profileBundle={profileBundle} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
app/page.tsx
Normal file
196
app/page.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signOutAction } from "@/app/auth-actions";
|
||||||
|
import { getAuthNotice } from "@/lib/auth/messages";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const headerActionClassName =
|
||||||
|
"shrink-0 whitespace-nowrap rounded-full border border-black/10 bg-white/70 px-4 py-2 text-sm font-medium text-slate-900 shadow-sm transition hover:-translate-y-0.5";
|
||||||
|
|
||||||
|
const loopSteps = [
|
||||||
|
{
|
||||||
|
title: "Check-in",
|
||||||
|
copy: "Start de dag met een korte energiescore en slaapkwaliteit, zonder overbodige frictie.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Plannen",
|
||||||
|
copy: "Verdeel activiteiten over de dag met een licht energiebudget en duidelijke prioriteiten.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Evalueren",
|
||||||
|
copy: "Kijk rustig terug op wat wel, niet of aangepast is gelukt, zonder medische claims of oordeel.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const releaseFocus = [
|
||||||
|
"Alleen individuele gebruikers in release 1",
|
||||||
|
"Wellness/self-management positionering",
|
||||||
|
"Geen sharing, AI of medische workflows in de MVP",
|
||||||
|
"Vercel + Supabase als technische basis",
|
||||||
|
];
|
||||||
|
|
||||||
|
type HomePageProps = {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParamValue(
|
||||||
|
params: Record<string, string | string[] | undefined>,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home({ searchParams }: HomePageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const notice = getAuthNotice(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] text-slate-900">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-10 sm:px-8 lg:px-10">
|
||||||
|
<header className="mb-10 flex items-center justify-between border-b border-black/10 pb-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-600">
|
||||||
|
Inspannings Monitor
|
||||||
|
</p>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-3xl leading-tight sm:text-5xl">
|
||||||
|
Rustige basis voor een wellness-first MVP
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
{authState.isConfigured ? (
|
||||||
|
authState.isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className={headerActionClassName}
|
||||||
|
>
|
||||||
|
Naar dashboard
|
||||||
|
</Link>
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={headerActionClassName}
|
||||||
|
>
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className={headerActionClassName}
|
||||||
|
>
|
||||||
|
Inloggen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/sign-up"
|
||||||
|
className={headerActionClassName}
|
||||||
|
>
|
||||||
|
Account aanmaken
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-amber-900/15 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-900 shadow-sm">
|
||||||
|
Supabase nog niet geconfigureerd
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div className="mb-6 rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm leading-7 text-emerald-900">
|
||||||
|
{notice.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="grid gap-6 lg:grid-cols-[1.35fr_0.95fr]">
|
||||||
|
<article className="rounded-[2rem] border border-black/10 bg-white/70 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
|
||||||
|
<p className="mb-4 max-w-2xl text-lg leading-8 text-slate-700">
|
||||||
|
De projectbasis staat nu, inclusief de eerste auth-laag via Supabase.
|
||||||
|
Release 1 blijft bewust smal: publieke landing, aparte login/signup
|
||||||
|
routes en een eerste protected dashboard als basis voor de volgende stories.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{loopSteps.map((step, index) => (
|
||||||
|
<section
|
||||||
|
key={step.title}
|
||||||
|
className="rounded-[1.5rem] border border-black/8 bg-stone-50 p-5"
|
||||||
|
>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-slate-500">
|
||||||
|
Stap {index + 1}
|
||||||
|
</p>
|
||||||
|
<h2 className="mb-2 font-[family-name:var(--font-display)] text-2xl">
|
||||||
|
{step.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-7 text-slate-700">{step.copy}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="rounded-[2rem] border border-emerald-950/10 bg-emerald-950 px-6 py-7 text-emerald-50 shadow-[0_18px_60px_rgba(6,78,59,0.18)] sm:px-8">
|
||||||
|
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-emerald-200/80">
|
||||||
|
Release 1 focus
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{releaseFocus.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/8 px-4 py-3 text-sm leading-7"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{authState.isConfigured ? (
|
||||||
|
<p className="mt-5 text-sm leading-7 text-emerald-100/80">
|
||||||
|
Auth is ingericht met e-mail, wachtwoord en verplichte e-mailverificatie.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-5 text-sm leading-7 text-emerald-100/80">
|
||||||
|
Voeg `.env.local` toe om login, signup en protected routes lokaal te activeren.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8 grid gap-5 rounded-[2rem] border border-black/10 bg-white/60 p-6 shadow-[0_10px_45px_rgba(71,85,105,0.08)] backdrop-blur sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Volgende story
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold text-slate-900">
|
||||||
|
ST-201 Ochtendcheck-in
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Doelgroep
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold text-slate-900">Volwassen individuele gebruikers</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Positionering
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold text-slate-900">Wellness / self-management</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold text-slate-900">Auth, onboarding en settings actief</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/settings/actions.ts
Normal file
31
app/settings/actions.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
|
import { saveSettingsForCurrentUser } from "@/lib/profile/service";
|
||||||
|
import type { SettingsSubmission } from "@/lib/profile/types";
|
||||||
|
|
||||||
|
function getString(formData: FormData, key: string) {
|
||||||
|
const value = formData.get(key);
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoolean(formData: FormData, key: string) {
|
||||||
|
return formData.get(key) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSettingsSubmission(formData: FormData): SettingsSubmission {
|
||||||
|
return {
|
||||||
|
locale: getString(formData, "locale"),
|
||||||
|
timezone: getString(formData, "timezone"),
|
||||||
|
morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"),
|
||||||
|
morningReminderTime: getString(formData, "morningReminderTime") || null,
|
||||||
|
reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"),
|
||||||
|
showEnergyPoints: getBoolean(formData, "showEnergyPoints"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettingsAction(formData: FormData) {
|
||||||
|
await saveSettingsForCurrentUser(buildSettingsSubmission(formData));
|
||||||
|
redirect(buildPathWithQuery("/settings", { status: "saved" }));
|
||||||
|
}
|
||||||
138
app/settings/page.tsx
Normal file
138
app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { signOutAction } from "@/app/auth-actions";
|
||||||
|
import { SettingsForm } from "@/components/settings/settings-form";
|
||||||
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type SettingsPageProps = {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParamValue(
|
||||||
|
params: Record<string, string | string[] | undefined>,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingsNotice(status: string | null) {
|
||||||
|
if (status === "saved") {
|
||||||
|
return "Je instellingen zijn opgeslagen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SettingsPage({ searchParams }: SettingsPageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
||||||
|
if (!authState.isConfigured) {
|
||||||
|
redirect("/login?error=auth-not-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/settings"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileBundle = await getProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
if (!profileBundle) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/settings"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileBundle.profile.onboardingSeen) {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = getSettingsNotice(getParamValue(resolvedSearchParams, "status"));
|
||||||
|
const profileTitle =
|
||||||
|
profileBundle.profile.displayName ??
|
||||||
|
profileBundle.profile.email ??
|
||||||
|
authState.email ??
|
||||||
|
"Ingelogde gebruiker";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||||
|
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
<Link href="/dashboard" className="transition hover:text-slate-900">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Instellingen</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||||
|
Instellingen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
|
||||||
|
Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen
|
||||||
|
account en de wellness-first scope van release 1.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="rounded-full border border-black/10 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:-translate-y-0.5 hover:text-slate-950"
|
||||||
|
>
|
||||||
|
Terug naar dashboard
|
||||||
|
</Link>
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-full bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div className="rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm leading-7 text-emerald-900">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<SettingsForm profileBundle={profileBundle} />
|
||||||
|
|
||||||
|
<aside className="space-y-5">
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Account
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
{profileTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Huidige status
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
Onboarding {profileBundle.profile.onboardingCompleted ? "afgerond" : "later afronden"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Je kunt later altijd terug naar onboarding of direct verder bouwen op
|
||||||
|
deze voorkeuren in de dagflow.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
app/sign-up/page.tsx
Normal file
96
app/sign-up/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthNotice } from "@/components/auth/auth-notice";
|
||||||
|
import { AuthPanel } from "@/components/auth/auth-panel";
|
||||||
|
import { signUpAction } from "@/app/auth-actions";
|
||||||
|
import { getAuthNotice } from "@/lib/auth/messages";
|
||||||
|
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type SignUpPageProps = {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParamValue(
|
||||||
|
params: Record<string, string | string[] | undefined>,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const next = sanitizeNextPath(getParamValue(resolvedSearchParams, "next"));
|
||||||
|
|
||||||
|
if (authState.isAuthenticated) {
|
||||||
|
redirect(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = getAuthNotice(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
|
const loginHref = buildPathWithQuery("/login", { next });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPanel
|
||||||
|
eyebrow="Account aanmaken"
|
||||||
|
title="Maak je eerste account aan"
|
||||||
|
description="Release 1 gebruikt e-mail en wachtwoord als eenvoudige basis. Na registratie bevestig je eerst je e-mailadres."
|
||||||
|
footer={
|
||||||
|
<p>
|
||||||
|
Heb je al een account?{" "}
|
||||||
|
<Link href={loginHref} className="font-semibold text-emerald-900">
|
||||||
|
Log dan in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthNotice notice={notice} />
|
||||||
|
|
||||||
|
{!authState.isConfigured ? (
|
||||||
|
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-4 text-sm leading-7 text-sky-900">
|
||||||
|
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form action={signUpAction} className="space-y-4">
|
||||||
|
<input type="hidden" name="next" value={next} />
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
E-mailadres
|
||||||
|
<input
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
Wachtwoord
|
||||||
|
<input
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex w-full items-center justify-center rounded-2xl bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Account aanmaken
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</AuthPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components.json
Normal file
25
components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
25
components/auth/auth-notice.tsx
Normal file
25
components/auth/auth-notice.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { type AuthNotice } from "@/lib/auth/messages";
|
||||||
|
|
||||||
|
type AuthNoticeProps = {
|
||||||
|
notice: AuthNotice | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toneStyles = {
|
||||||
|
error: "border-rose-200 bg-rose-50 text-rose-900",
|
||||||
|
success: "border-emerald-200 bg-emerald-50 text-emerald-900",
|
||||||
|
info: "border-sky-200 bg-sky-50 text-sky-900",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthNotice({ notice }: AuthNoticeProps) {
|
||||||
|
if (!notice) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-5 rounded-2xl border px-4 py-3 text-sm leading-7 ${toneStyles[notice.tone]}`}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/auth/auth-panel.tsx
Normal file
64
components/auth/auth-panel.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type AuthPanelProps = {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthPanel({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: AuthPanelProps) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
||||||
|
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<section className="flex flex-col justify-between rounded-[2rem] border border-black/10 bg-emerald-950 p-7 text-emerald-50 shadow-[0_18px_60px_rgba(6,78,59,0.18)] sm:p-9">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-200/80">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-8 text-emerald-50/85">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-[1.5rem] border border-white/10 bg-white/8 p-5 text-sm leading-7 text-emerald-50/90">
|
||||||
|
<p className="font-semibold">Release 1 blijft bewust licht.</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
<li>Wellness-first en alleen voor individuele gebruikers</li>
|
||||||
|
<li>Geen zorgverlenerstoegang, sharing of AI in deze fase</li>
|
||||||
|
<li>Authenticatie via Supabase met cookie-based sessies</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex items-center">
|
||||||
|
<div className="w-full rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500 transition hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Terug naar landing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="mt-6 border-t border-black/10 pt-5 text-sm text-slate-600">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
components/onboarding/onboarding-flow.tsx
Normal file
292
components/onboarding/onboarding-flow.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { completeOnboardingAction, skipOnboardingAction } from "@/app/onboarding/actions";
|
||||||
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
|
import type { ProfileBundle } from "@/lib/profile/types";
|
||||||
|
|
||||||
|
type OnboardingFlowProps = {
|
||||||
|
profileBundle: ProfileBundle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
eyebrow: "Stap 1",
|
||||||
|
title: "Zo gebruiken we Inspannings Monitor",
|
||||||
|
description:
|
||||||
|
"De app helpt je om je dag rustiger te plannen en terug te kijken zonder medische claims of zorgverlenerfuncties.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Stap 2",
|
||||||
|
title: "Basisprofiel",
|
||||||
|
description:
|
||||||
|
"Kies hoe de app je mag aanspreken en welke timezone het best bij je dagindeling past.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Stap 3",
|
||||||
|
title: "Startvoorkeuren",
|
||||||
|
description:
|
||||||
|
"Kies rustig hoe zichtbaar je energiebudget is en of je lichte reminders wilt ontvangen.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function OnboardingFlow({ profileBundle }: OnboardingFlowProps) {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [displayName, setDisplayName] = useState(profileBundle.profile.displayName ?? "");
|
||||||
|
const [timezone, setTimezone] = useState(profileBundle.profile.timezone);
|
||||||
|
const [showEnergyPoints, setShowEnergyPoints] = useState(
|
||||||
|
profileBundle.settings.showEnergyPoints,
|
||||||
|
);
|
||||||
|
const [morningReminderEnabled, setMorningReminderEnabled] = useState(
|
||||||
|
profileBundle.settings.morningReminderEnabled,
|
||||||
|
);
|
||||||
|
const [morningReminderTime, setMorningReminderTime] = useState(
|
||||||
|
profileBundle.settings.morningReminderTime ?? "08:30",
|
||||||
|
);
|
||||||
|
const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState(
|
||||||
|
profileBundle.settings.reflectionReminderEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const step = steps[currentStep];
|
||||||
|
const isFirstStep = currentStep === 0;
|
||||||
|
const isLastStep = currentStep === steps.length - 1;
|
||||||
|
|
||||||
|
function goToPreviousStep() {
|
||||||
|
setCurrentStep((stepIndex) => Math.max(0, stepIndex - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToNextStep(event: MouseEvent<HTMLButtonElement>) {
|
||||||
|
// This button lives inside the onboarding form. By preventing the default
|
||||||
|
// click action and rendering a keyed replacement, we avoid an accidental
|
||||||
|
// form submit when the final step button appears after the state update.
|
||||||
|
event.preventDefault();
|
||||||
|
setCurrentStep((stepIndex) => Math.min(steps.length - 1, stepIndex + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<section className="rounded-[2rem] border border-black/10 bg-emerald-950 p-7 text-emerald-50 shadow-[0_18px_60px_rgba(6,78,59,0.18)] sm:p-9">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-200/80">
|
||||||
|
{step.eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
|
||||||
|
{step.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-8 text-emerald-50/85">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-[1.5rem] border border-white/10 bg-white/8 p-5 text-sm leading-7 text-emerald-50/90">
|
||||||
|
<p className="font-semibold">Release 1 blijft bewust wellness-first.</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
<li>Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang.</li>
|
||||||
|
<li>De app geeft geen diagnose, behandeling of medisch advies.</li>
|
||||||
|
<li>Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="mt-8 flex gap-3">
|
||||||
|
{steps.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.title}
|
||||||
|
className={`h-2 flex-1 rounded-full ${
|
||||||
|
index <= currentStep ? "bg-emerald-200" : "bg-white/15"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Korte onboarding
|
||||||
|
</p>
|
||||||
|
<form action={skipOnboardingAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-full border border-black/10 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:-translate-y-0.5 hover:text-slate-950"
|
||||||
|
>
|
||||||
|
Nu overslaan
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={completeOnboardingAction} className="space-y-6">
|
||||||
|
<input type="hidden" name="displayName" value={displayName} />
|
||||||
|
<input type="hidden" name="timezone" value={timezone} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="showEnergyPoints"
|
||||||
|
value={showEnergyPoints ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="morningReminderEnabled"
|
||||||
|
value={morningReminderEnabled ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="morningReminderTime" value={morningReminderTime} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="reflectionReminderEnabled"
|
||||||
|
value={reflectionReminderEnabled ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentStep === 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<article className="rounded-[1.5rem] border border-black/10 bg-stone-50 p-5">
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-2xl text-slate-900">
|
||||||
|
Wat je hier wél krijgt
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Een rustige plan-doe-evalueer flow met energiebudgetten, zonder
|
||||||
|
druk, score-oordeel of medische terminologie.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.5rem] border border-black/10 bg-stone-50 p-5">
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-2xl text-slate-900">
|
||||||
|
Wat deze app niet doet
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
Geen diagnose, geen behandeling, geen medische triage en geen
|
||||||
|
automatisch delen met derden.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{currentStep === 1 ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
Schermnaam
|
||||||
|
<input
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
placeholder="Optioneel, bijvoorbeeld Jan"
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="rounded-[1.5rem] border border-sky-200 bg-sky-50 px-4 py-4 text-sm leading-7 text-sky-900">
|
||||||
|
Voertaal voor release 1 staat vast op <strong>Nederlands</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
Timezone
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
value={timezone}
|
||||||
|
onChange={(event) => setTimezone(event.target.value)}
|
||||||
|
>
|
||||||
|
{ONBOARDING_TIMEZONE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{currentStep === 2 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-3 rounded-[1.5rem] border border-black/10 bg-stone-50 px-4 py-4">
|
||||||
|
<input
|
||||||
|
className="mt-1 h-4 w-4 accent-emerald-900"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showEnergyPoints}
|
||||||
|
onChange={(event) => setShowEnergyPoints(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-slate-900">
|
||||||
|
Toon energiebudgetpunten
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm leading-7 text-slate-700">
|
||||||
|
Laat geplande en resterende punten zichtbaar zien in de interface.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-[1.5rem] border border-black/10 bg-stone-50 px-4 py-4">
|
||||||
|
<input
|
||||||
|
className="mt-1 h-4 w-4 accent-emerald-900"
|
||||||
|
type="checkbox"
|
||||||
|
checked={morningReminderEnabled}
|
||||||
|
onChange={(event) => setMorningReminderEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-semibold text-slate-900">
|
||||||
|
Zet een lichte ochtendreminder aan
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm leading-7 text-slate-700">
|
||||||
|
Handig als je later een korte check-in wilt doen zonder extra druk.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{morningReminderEnabled ? (
|
||||||
|
<input
|
||||||
|
className="mt-3 w-full rounded-2xl border border-black/10 bg-white px-4 py-3 text-base outline-none transition focus:border-emerald-600"
|
||||||
|
type="time"
|
||||||
|
value={morningReminderTime}
|
||||||
|
onChange={(event) => setMorningReminderTime(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-[1.5rem] border border-black/10 bg-stone-50 px-4 py-4">
|
||||||
|
<input
|
||||||
|
className="mt-1 h-4 w-4 accent-emerald-900"
|
||||||
|
type="checkbox"
|
||||||
|
checked={reflectionReminderEnabled}
|
||||||
|
onChange={(event) => setReflectionReminderEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-slate-900">
|
||||||
|
Sta lichte reflectieprompts toe
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm leading-7 text-slate-700">
|
||||||
|
Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-black/10 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToPreviousStep}
|
||||||
|
disabled={isFirstStep}
|
||||||
|
className="rounded-full border border-black/10 bg-white px-5 py-3 text-sm font-medium text-slate-700 transition hover:-translate-y-0.5 hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
>
|
||||||
|
Vorige
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLastStep ? (
|
||||||
|
<button
|
||||||
|
key="complete-onboarding"
|
||||||
|
type="submit"
|
||||||
|
className="rounded-full bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Rond onboarding af
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={`next-step-${currentStep}`}
|
||||||
|
type="button"
|
||||||
|
onClick={goToNextStep}
|
||||||
|
className="rounded-full bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Ga verder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
components/settings/settings-form.tsx
Normal file
217
components/settings/settings-form.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { saveSettingsAction } from "@/app/settings/actions";
|
||||||
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
|
import type { ProfileBundle } from "@/lib/profile/types";
|
||||||
|
|
||||||
|
type SettingsFormProps = {
|
||||||
|
profileBundle: ProfileBundle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCALE_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "nl-NL",
|
||||||
|
label: "Nederlands",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
|
const [locale, setLocale] = useState(profileBundle.profile.locale);
|
||||||
|
const [timezone, setTimezone] = useState(profileBundle.profile.timezone);
|
||||||
|
const [showEnergyPoints, setShowEnergyPoints] = useState(
|
||||||
|
profileBundle.settings.showEnergyPoints,
|
||||||
|
);
|
||||||
|
const [morningReminderEnabled, setMorningReminderEnabled] = useState(
|
||||||
|
profileBundle.settings.morningReminderEnabled,
|
||||||
|
);
|
||||||
|
const [morningReminderTime, setMorningReminderTime] = useState(
|
||||||
|
profileBundle.settings.morningReminderTime ?? "08:30",
|
||||||
|
);
|
||||||
|
const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState(
|
||||||
|
profileBundle.settings.reflectionReminderEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={saveSettingsAction} className="space-y-6">
|
||||||
|
<input type="hidden" name="locale" value={locale} />
|
||||||
|
<input type="hidden" name="timezone" value={timezone} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="showEnergyPoints"
|
||||||
|
value={showEnergyPoints ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="morningReminderEnabled"
|
||||||
|
value={morningReminderEnabled ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="morningReminderTime" value={morningReminderTime} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="reflectionReminderEnabled"
|
||||||
|
value={reflectionReminderEnabled ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Account
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-slate-900">
|
||||||
|
Basisinstellingen voor jouw account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-700">
|
||||||
|
Je past hier alleen je wellness-first voorkeuren aan. Er zijn in release 1
|
||||||
|
geen medische velden, deelinstellingen of zorgverlenerrollen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Taal en tijd
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
Taal
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
value={locale}
|
||||||
|
onChange={(event) => setLocale(event.target.value)}
|
||||||
|
>
|
||||||
|
{LOCALE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="rounded-[1.5rem] border border-sky-200 bg-sky-50 px-4 py-4 text-sm leading-7 text-sky-900">
|
||||||
|
Release 1 draait bewust volledig in het Nederlands. De taalinstelling
|
||||||
|
blijft al wel aanwezig in het accountmodel.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-slate-800">
|
||||||
|
Timezone
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
|
||||||
|
value={timezone}
|
||||||
|
onChange={(event) => setTimezone(event.target.value)}
|
||||||
|
>
|
||||||
|
{ONBOARDING_TIMEZONE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Interface
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<label className="flex items-start gap-3 rounded-[1.5rem] border border-black/10 bg-stone-50 px-4 py-4">
|
||||||
|
<input
|
||||||
|
className="mt-1 h-4 w-4 accent-emerald-900"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showEnergyPoints}
|
||||||
|
onChange={(event) => setShowEnergyPoints(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-slate-900">
|
||||||
|
Toon energiebudgetpunten
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm leading-7 text-slate-700">
|
||||||
|
Laat budgetpunten zichtbaar zien in het dashboard en latere dagflows.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
Reminders
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<label className="flex items-start gap-3 rounded-[1.5rem] border border-black/10 bg-stone-50 px-4 py-4">
|
||||||
|
<input
|
||||||
|
className="mt-1 h-4 w-4 accent-emerald-900"
|
||||||
|
type="checkbox"
|
||||||
|
checked={morningReminderEnabled}
|
||||||
|
onChange={(event) => setMorningReminderEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-semibold text-slate-900">
|
||||||
|
Ochtendreminder
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm leading-7 text-slate-700">
|
||||||
|
Zet een lichte reminder aan voor een rustige start van je check-in.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{morningReminderEnabled ? (
|
||||||
|
<input
|
||||||
|
className="mt-3 w-full rounded-2xl border border-black/10 bg-white px-4 py-3 text-base outline-none transition focus:border-emerald-600"
|
||||||
|
type="time"
|
||||||
|
value={morningReminderTime}
|
||||||
|
onChange={(event) => setMorningReminderTime(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-[1.5rem] border border-black/10 bg-stone-50 px-4 py-4">
|
||||||
|
<input
|
||||||
|
className="mt-1 h-4 w-4 accent-emerald-900"
|
||||||
|
type="checkbox"
|
||||||
|
checked={reflectionReminderEnabled}
|
||||||
|
onChange={(event) => setReflectionReminderEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-slate-900">
|
||||||
|
Reflectieprompts toestaan
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm leading-7 text-slate-700">
|
||||||
|
Maak alvast de opt-in klaar voor lichte terugblikprompts in een latere story.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[1.75rem] border border-emerald-950/10 bg-emerald-950 p-6 text-emerald-50 shadow-[0_12px_40px_rgba(6,78,59,0.18)]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-200/80">
|
||||||
|
Bewuste grenzen
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm leading-7 text-emerald-50/90">
|
||||||
|
<li>Geen medische drempels, diagnoses of behandelinstellingen.</li>
|
||||||
|
<li>Geen delen met zorgverleners of naasten in release 1.</li>
|
||||||
|
<li>Alle instellingen blijven gekoppeld aan alleen jouw account.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-black/10 pt-6">
|
||||||
|
<p className="text-sm leading-7 text-slate-600">
|
||||||
|
Wijzigingen zijn direct van toepassing op jouw account en volgende sessies.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-full bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
|
||||||
|
>
|
||||||
|
Instellingen opslaan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/ui/button.tsx
Normal file
58
components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
53
docs/README.md
Normal file
53
docs/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Inspannings Monitor Documentatieset
|
||||||
|
|
||||||
|
Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-management` route van `Inspannings Monitor`, met een expliciet opengehouden pad naar een latere medische producttrack.
|
||||||
|
|
||||||
|
## Huidige leidende documenten
|
||||||
|
|
||||||
|
- [inspannings-monitor-01-productkader-en-positionering-v06.docx](./inspannings-monitor-01-productkader-en-positionering-v06.docx)
|
||||||
|
Legt intended use, non-intended use, scope, doelgroep en claim-guardrails vast.
|
||||||
|
|
||||||
|
- [inspannings-monitor-02-functionele-specificatie-mvp-v06.docx](./inspannings-monitor-02-functionele-specificatie-mvp-v06.docx)
|
||||||
|
Beschrijft de wellness-first MVP in toetsbare functionele requirements.
|
||||||
|
|
||||||
|
- [inspannings-monitor-03-privacy-security-safety-baseline-v02.docx](./inspannings-monitor-03-privacy-security-safety-baseline-v02.docx)
|
||||||
|
Bundelt de minimale randvoorwaarden voor privacy, informatiebeveiliging en productveiligheid.
|
||||||
|
|
||||||
|
- [inspannings-monitor-04-roadmap-wellness-naar-medisch-v02.docx](./inspannings-monitor-04-roadmap-wellness-naar-medisch-v02.docx)
|
||||||
|
Laat zien hoe het product gecontroleerd kan doorgroeien zonder ongemerkt medische scope binnen te trekken.
|
||||||
|
|
||||||
|
- [inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx](./inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx)
|
||||||
|
Brengt de technische implementatielaag uit de oude specificatie terug als apart architectuurdocument voor de wellness-first MVP.
|
||||||
|
|
||||||
|
- [inspannings-monitor-06-implementatieplan-en-backlog-v01.docx](./inspannings-monitor-06-implementatieplan-en-backlog-v01.docx)
|
||||||
|
Zet de documentatieset om naar een uitvoerbare backlog met epics, stories, afhankelijkheden en releasevolgorde.
|
||||||
|
|
||||||
|
## Bevestigde uitgangspunten
|
||||||
|
|
||||||
|
- Productnaam: `Inspannings Monitor`
|
||||||
|
- Positionering eerste release: `wellness/self-management`
|
||||||
|
- Release 1: alleen individuele gebruikers
|
||||||
|
- Doelgroep: `volwassenen`
|
||||||
|
- Voertaal eerste release: `Nederlands`
|
||||||
|
- Hosting: `Vercel`
|
||||||
|
- Database: `Supabase PostgreSQL`
|
||||||
|
- Authenticatie: `Supabase Auth`
|
||||||
|
|
||||||
|
## Generator
|
||||||
|
|
||||||
|
- [generate_inspannings_monitor_docs.py](./generate_inspannings_monitor_docs.py)
|
||||||
|
Genereert de actuele `.docx`-documenten opnieuw vanuit de bevestigde uitgangspunten.
|
||||||
|
|
||||||
|
## Backlog en Linear
|
||||||
|
|
||||||
|
- [backlog/inspannings-monitor-backlog.md](./backlog/inspannings-monitor-backlog.md)
|
||||||
|
- [backlog/inspannings-monitor-backlog.csv](./backlog/inspannings-monitor-backlog.csv)
|
||||||
|
- [backlog/inspannings-monitor-linear-setup.md](./backlog/inspannings-monitor-linear-setup.md)
|
||||||
|
- [backlog/inspannings-monitor-linear-import-checklist.md](./backlog/inspannings-monitor-linear-import-checklist.md)
|
||||||
|
- [backlog/inspannings-monitor-linear-projects.md](./backlog/inspannings-monitor-linear-projects.md)
|
||||||
|
- [backlog/inspannings-monitor-linear-eerste-30-minuten.md](./backlog/inspannings-monitor-linear-eerste-30-minuten.md)
|
||||||
|
- [backlog/generate_linear_backlog_assets.py](./backlog/generate_linear_backlog_assets.py)
|
||||||
|
|
||||||
|
## Oudere documenten
|
||||||
|
|
||||||
|
De eerdere `EnergyPace`-documenten in deze map zijn niet leidend meer. De `Inspannings Monitor`-documenten hierboven zijn de actuele basis.
|
||||||
332
docs/backlog/generate_linear_backlog_assets.py
Normal file
332
docs/backlog/generate_linear_backlog_assets.py
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path("/Users/janpetervisser/Development/third/docs/backlog")
|
||||||
|
SOURCE_CSV = BASE_DIR / "inspannings-monitor-backlog.csv"
|
||||||
|
LINEAR_CSV = BASE_DIR / "inspannings-monitor-linear-import-issues.csv"
|
||||||
|
PROJECTS_CSV = BASE_DIR / "inspannings-monitor-linear-projects.csv"
|
||||||
|
|
||||||
|
TEAM_NAME = "Inspannings Monitor"
|
||||||
|
INITIATIVE_NAME = "Release 1 MVP"
|
||||||
|
CREATED_AT = "2026-04-17T00:00:00Z"
|
||||||
|
|
||||||
|
LINEAR_HEADERS = [
|
||||||
|
"ID",
|
||||||
|
"Team",
|
||||||
|
"Title",
|
||||||
|
"Description",
|
||||||
|
"Status",
|
||||||
|
"Estimate",
|
||||||
|
"Priority",
|
||||||
|
"Project ID",
|
||||||
|
"Project",
|
||||||
|
"Creator",
|
||||||
|
"Assignee",
|
||||||
|
"Labels",
|
||||||
|
"Cycle Number",
|
||||||
|
"Cycle Name",
|
||||||
|
"Cycle Start",
|
||||||
|
"Cycle End",
|
||||||
|
"Created",
|
||||||
|
"Updated",
|
||||||
|
"Started",
|
||||||
|
"Triaged",
|
||||||
|
"Completed",
|
||||||
|
"Canceled",
|
||||||
|
"Archived",
|
||||||
|
"Due Date",
|
||||||
|
"Parent issue",
|
||||||
|
"Initiatives",
|
||||||
|
"Project Milestone ID",
|
||||||
|
"Project Milestone",
|
||||||
|
"SLA Status",
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIORITY_MAP = {
|
||||||
|
"P0": "high",
|
||||||
|
"P1": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_rows():
|
||||||
|
with SOURCE_CSV.open(newline="", encoding="utf-8") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def build_epic_map(rows):
|
||||||
|
return {
|
||||||
|
row["ID"]: row["Title"]
|
||||||
|
for row in rows
|
||||||
|
if row.get("Issue Type") == "Epic"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_labels(raw):
|
||||||
|
labels = [label.strip() for label in raw.split(";") if label.strip()]
|
||||||
|
return ", ".join(labels)
|
||||||
|
|
||||||
|
|
||||||
|
def build_description(row, epic_title):
|
||||||
|
parts = [
|
||||||
|
row["Description"].strip(),
|
||||||
|
"",
|
||||||
|
"## Context",
|
||||||
|
f"- Bron backlog-ID: `{row['ID']}`",
|
||||||
|
f"- Epic / project: `{epic_title}`",
|
||||||
|
f"- Fase: `{row['Phase']}`",
|
||||||
|
]
|
||||||
|
depends_on = row.get("Depends On", "").strip()
|
||||||
|
if depends_on:
|
||||||
|
parts.append(f"- Afhankelijk van: `{depends_on}`")
|
||||||
|
definition = row.get("Definition of Done", "").strip()
|
||||||
|
if definition:
|
||||||
|
parts.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Definition of done",
|
||||||
|
definition,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_linear_rows(rows):
|
||||||
|
epic_map = build_epic_map(rows)
|
||||||
|
linear_rows = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.get("Issue Type") != "Story":
|
||||||
|
continue
|
||||||
|
|
||||||
|
epic_id = row.get("Epic", "").strip()
|
||||||
|
epic_title = epic_map.get(epic_id, "")
|
||||||
|
labels = normalize_labels(row.get("Labels", ""))
|
||||||
|
|
||||||
|
linear_rows.append(
|
||||||
|
{
|
||||||
|
"ID": "",
|
||||||
|
"Team": TEAM_NAME,
|
||||||
|
"Title": row["Title"].strip(),
|
||||||
|
"Description": build_description(row, epic_title),
|
||||||
|
"Status": "Backlog",
|
||||||
|
"Estimate": "",
|
||||||
|
"Priority": PRIORITY_MAP.get(row.get("Priority", "").strip(), ""),
|
||||||
|
"Project ID": "",
|
||||||
|
"Project": epic_title,
|
||||||
|
"Creator": "",
|
||||||
|
"Assignee": "",
|
||||||
|
"Labels": labels,
|
||||||
|
"Cycle Number": "",
|
||||||
|
"Cycle Name": "",
|
||||||
|
"Cycle Start": "",
|
||||||
|
"Cycle End": "",
|
||||||
|
"Created": CREATED_AT,
|
||||||
|
"Updated": "",
|
||||||
|
"Started": "",
|
||||||
|
"Triaged": "",
|
||||||
|
"Completed": "",
|
||||||
|
"Canceled": "",
|
||||||
|
"Archived": "",
|
||||||
|
"Due Date": "",
|
||||||
|
"Parent issue": "",
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
"Project Milestone ID": "",
|
||||||
|
"Project Milestone": "",
|
||||||
|
"SLA Status": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return linear_rows
|
||||||
|
|
||||||
|
|
||||||
|
def write_linear_csv(rows):
|
||||||
|
with LINEAR_CSV.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=LINEAR_HEADERS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def write_projects_csv():
|
||||||
|
headers = [
|
||||||
|
"Name",
|
||||||
|
"Summary",
|
||||||
|
"Status",
|
||||||
|
"Milestones",
|
||||||
|
"Creator",
|
||||||
|
"Lead",
|
||||||
|
"Members",
|
||||||
|
"Created At",
|
||||||
|
"Started At",
|
||||||
|
"Target Date",
|
||||||
|
"Completed At",
|
||||||
|
"Canceled At",
|
||||||
|
"Teams",
|
||||||
|
"Initiatives",
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"Name": "Fundament",
|
||||||
|
"Summary": "Leg de technische basis voor release 1 met projectsetup, omgevingen, UI-basis en foutafhandeling.",
|
||||||
|
"Status": "Planned",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Auth en profiel",
|
||||||
|
"Summary": "Implementeer accounttoegang, profieldata, onboarding en basisinstellingen per gebruiker.",
|
||||||
|
"Status": "Planned",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Ochtendcheck-in",
|
||||||
|
"Summary": "Bouw de ochtendcheck-in met energiescore, slaapkwaliteit en automatische budgetafleiding.",
|
||||||
|
"Status": "Planned",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Dagplanning",
|
||||||
|
"Summary": "Maak plannen van activiteiten mogelijk met budgetfeedback, energie-impact en prioriteit.",
|
||||||
|
"Status": "Planned",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Evaluatie en dagoverzicht",
|
||||||
|
"Summary": "Maak evaluatie van activiteiten en een dagelijks overzicht van gepland versus uitgevoerd mogelijk.",
|
||||||
|
"Status": "Planned",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Weekoverzicht en inzichten",
|
||||||
|
"Summary": "Voeg weekterugblik, eenvoudige aggregaties en veilige patroonweergave toe zonder medische claims.",
|
||||||
|
"Status": "Backlog",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Reflectie en reminders",
|
||||||
|
"Summary": "Voeg optionele T+1/T+2 reflectieprompts en lichte reminderlogica toe voor zwaardere dagen.",
|
||||||
|
"Status": "Backlog",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Security en operations",
|
||||||
|
"Summary": "Borg logging, rate limiting, secrets, back-up en owner-only toegangscontrole voor echte gebruikersintroductie.",
|
||||||
|
"Status": "Planned",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Launch-readiness",
|
||||||
|
"Summary": "Rond QA, copy review, accessibility checks, DPIA-input en go-live checks voor release 1 af.",
|
||||||
|
"Status": "Backlog",
|
||||||
|
"Milestones": "",
|
||||||
|
"Creator": "",
|
||||||
|
"Lead": "",
|
||||||
|
"Members": "",
|
||||||
|
"Created At": CREATED_AT,
|
||||||
|
"Started At": "",
|
||||||
|
"Target Date": "",
|
||||||
|
"Completed At": "",
|
||||||
|
"Canceled At": "",
|
||||||
|
"Teams": TEAM_NAME,
|
||||||
|
"Initiatives": INITIATIVE_NAME,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with PROJECTS_CSV.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=headers)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = read_rows()
|
||||||
|
linear_rows = build_linear_rows(rows)
|
||||||
|
write_linear_csv(linear_rows)
|
||||||
|
write_projects_csv()
|
||||||
|
print(LINEAR_CSV)
|
||||||
|
print(f"rows={len(linear_rows)}")
|
||||||
|
print(PROJECTS_CSV)
|
||||||
|
print("projects=9")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
53
docs/backlog/inspannings-monitor-backlog.csv
Normal file
53
docs/backlog/inspannings-monitor-backlog.csv
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"ID","Title","Description","Issue Type","Epic","Priority","Status","Phase","Labels","Depends On","Definition of Done"
|
||||||
|
"EPIC-01","Fundament","Projectbasis, omgevingen en design foundation neerzetten.","Epic","","P0","Todo","Release 1","release:r1;domain:platform","",""
|
||||||
|
"ST-001","Next.js projectbasis opzetten","Zet de projectbasis op met TypeScript en de gekozen stylingaanpak.","Story","EPIC-01","P0","Todo","Release 1","release:r1;epic:fundament;type:build","EPIC-01","Project start lokaal en in preview zonder handmatige workarounds."
|
||||||
|
"ST-002","Omgevingen definiëren","Richt development, preview en production technisch in.","Story","EPIC-01","P0","Todo","Release 1","release:r1;epic:fundament;type:ops","ST-001","Development, preview en production zijn technisch ingericht."
|
||||||
|
"ST-003","Component foundation neerzetten","Bouw herbruikbare basiscomponenten voor formulieren, kaarten, knoppen en meldingen.","Story","EPIC-01","P0","Todo","Release 1","release:r1;epic:fundament;type:ui","ST-001","Herbruikbare basiscomponenten zijn mobiel bruikbaar."
|
||||||
|
"ST-004","Foutafhandeling en lege staten ontwerpen","Ontwerp en implementeer lege staten en bruikbare foutfeedback.","Story","EPIC-01","P0","Todo","Release 1","release:r1;epic:fundament;type:ux","ST-001","Gebruiker krijgt bruikbare feedback bij lege of foutieve situaties."
|
||||||
|
"EPIC-02","Auth en profiel","Inloggen, sessies, profiel en basisinstellingen werkend maken.","Epic","","P0","Todo","Release 1","release:r1;domain:auth","EPIC-01",""
|
||||||
|
"ST-101","Supabase Auth integreren","Integreer Supabase Auth en de sessieflow in de app.","Story","EPIC-02","P0","Todo","Release 1","release:r1;epic:auth-profiel;type:build","EPIC-01","Gebruiker kan inloggen en beveiligde routes gebruiken."
|
||||||
|
"ST-102","Profile- en UserSettings-model implementeren","Implementeer profiel- en settingsmodellen per gebruiker.","Story","EPIC-02","P0","Todo","Release 1","release:r1;epic:auth-profiel;type:build","ST-101","Profiel en instellingen zijn per gebruiker beschikbaar."
|
||||||
|
"ST-103","Onboardingflow bouwen","Bouw een onboarding van maximaal drie schermen.","Story","EPIC-02","P0","Todo","Release 1","release:r1;epic:auth-profiel;type:ux","ST-101","Nieuwe gebruiker begrijpt schaal, positionering en basisinstellingen."
|
||||||
|
"ST-104","Settingsscherm bouwen","Bouw instellingen voor taal, timezone, reminders en zichtbaarheid van punten.","Story","EPIC-02","P0","Todo","Release 1","release:r1;epic:auth-profiel;type:build","ST-102","Taal, timezone, reminders en zichtbaarheid van punten zijn persistent."
|
||||||
|
"ST-105","RLS-basispolicies inrichten","Richt owner-only RLS-policies in voor profiel en instellingen.","Story","EPIC-02","P0","Todo","Release 1","release:r1;epic:auth-profiel;type:security","ST-101","Gebruiker kan uitsluitend eigen profiel en settings lezen of wijzigen."
|
||||||
|
"EPIC-03","Ochtendcheck-in","Energiescore, slaapkwaliteit en dagbudget implementeren.","Epic","","P0","Todo","Release 1","release:r1;domain:checkin","EPIC-02",""
|
||||||
|
"ST-201","EnergySlider en SleepQualityInput bouwen","Bouw de invoercomponenten voor energiescore en slaapkwaliteit.","Story","EPIC-03","P0","Todo","Release 1","release:r1;epic:ochtendcheckin;type:ui","EPIC-02","Check-in kan mobiel comfortabel worden ingevuld."
|
||||||
|
"ST-202","Server action voor createMorningCheckIn","Implementeer de server action voor het opslaan van de ochtendcheck-in.","Story","EPIC-03","P0","Todo","Release 1","release:r1;epic:ochtendcheckin;type:build","ST-201","Check-in wordt opgeslagen met juiste validatie."
|
||||||
|
"ST-203","Budgetlogica implementeren","Bouw mapping van score naar energy level en dagbudget.","Story","EPIC-03","P0","Todo","Release 1","release:r1;epic:ochtendcheckin;type:logic","ST-202","Score mapping en budgetberekening zijn consistent en testbaar."
|
||||||
|
"ST-204","Check-instatus op dashboard tonen","Toon direct score, niveau en budget op het dashboard.","Story","EPIC-03","P0","Todo","Release 1","release:r1;epic:ochtendcheckin;type:ui","ST-202","Gebruiker ziet direct score, niveau en budget."
|
||||||
|
"ST-205","Unit tests voor score- en budgetmapping","Voeg tests toe voor grenswaarden en budgetberekening.","Story","EPIC-03","P0","Todo","Release 1","release:r1;epic:ochtendcheckin;type:qa","ST-203","Belangrijkste grenswaarden zijn afgedekt."
|
||||||
|
"EPIC-04","Dagplanning","Activiteiten plannen en budgetfeedback tonen.","Epic","","P0","Todo","Release 1","release:r1;domain:planning","EPIC-03",""
|
||||||
|
"ST-301","Datamodel voor activiteiten implementeren","Implementeer tabellen en seed-data voor activiteiten, categorieen en skip-redenen.","Story","EPIC-04","P0","Todo","Release 1","release:r1;epic:dagplanning;type:build","EPIC-03","Migraties en seed-data voor categorieen en skip-redenen zijn aanwezig."
|
||||||
|
"ST-302","Planningformulier bouwen","Bouw het formulier voor naam, categorie, duur, impact en prioriteit.","Story","EPIC-04","P0","Todo","Release 1","release:r1;epic:dagplanning;type:ui","ST-301","Activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt."
|
||||||
|
"ST-303","Autocomplete op eerdere activiteiten toevoegen","Maak snelle herselectie van eerder gebruikte activiteiten mogelijk.","Story","EPIC-04","P0","Todo","Release 1","release:r1;epic:dagplanning;type:ux","ST-302","Veelgebruikte activiteiten zijn snel opnieuw te kiezen."
|
||||||
|
"ST-304","EnergyMeter en lopend totaal implementeren","Toon het lopende totaal ten opzichte van het dagbudget.","Story","EPIC-04","P0","Todo","Release 1","release:r1;epic:dagplanning;type:logic-ui","ST-302","Totaal update direct na elke wijziging."
|
||||||
|
"ST-305","Overschrijdingswaarschuwing toevoegen","Toon een niet-blokkerende waarschuwing bij budgetoverschrijding.","Story","EPIC-04","P0","Todo","Release 1","release:r1;epic:dagplanning;type:ux","ST-304","Gebruiker krijgt feedback maar behoudt regie."
|
||||||
|
"EPIC-05","Evaluatie en dagoverzicht","Activiteiten afronden en dagresultaat tonen.","Epic","","P0","Todo","Release 1","release:r1;domain:evaluatie","EPIC-04",""
|
||||||
|
"ST-401","Statusflows voor uitgevoerd, geskipt en aangepast bouwen","Implementeer de drie kernstatussen voor activiteiten.","Story","EPIC-05","P0","Todo","Release 1","release:r1;epic:evaluatie;type:build","EPIC-04","Alle drie de statussen worden correct opgeslagen."
|
||||||
|
"ST-402","Evaluatievelden toevoegen","Voeg contextuele velden toe voor werkelijke duur, fatigue en skip-reden.","Story","EPIC-05","P0","Todo","Release 1","release:r1;epic:evaluatie;type:ui","ST-401","Contextuele velden verschijnen passend per status."
|
||||||
|
"ST-403","Ongeplande activiteiten ondersteunen","Maak het mogelijk een ongeplande activiteit toe te voegen en mee te tellen.","Story","EPIC-05","P0","Todo","Release 1","release:r1;epic:evaluatie;type:build","ST-401","Ongeplande activiteit telt mee in werkelijke totalen."
|
||||||
|
"ST-404","Dagoverzicht bouwen","Bouw het overzicht met gepland versus uitgevoerd en statusverdeling.","Story","EPIC-05","P0","Todo","Release 1","release:r1;epic:evaluatie;type:ui","ST-401","Gepland versus uitgevoerd en statusverdeling zijn zichtbaar."
|
||||||
|
"ST-405","Dagaggregaties server-side implementeren","Bereken dagtotalen en samenvatting server-side.","Story","EPIC-05","P0","Todo","Release 1","release:r1;epic:evaluatie;type:logic","ST-404","Dagtotalen blijven consistent met individuele records."
|
||||||
|
"EPIC-06","Weekoverzicht en inzichten","Weekpatronen en veilige insightregels toevoegen.","Epic","","P1","Todo","Release 1","release:r1;domain:insights","EPIC-05",""
|
||||||
|
"ST-501","Weekoverzichtspagina bouwen","Bouw de pagina voor weekterugblik.","Story","EPIC-06","P1","Todo","Release 1","release:r1;epic:weekoverzicht;type:ui","EPIC-05","Gebruiker kan per week terugkijken."
|
||||||
|
"ST-502","Weekaggregaties bouwen","Bereken gemiddelde energie en budget-adherence per week.","Story","EPIC-06","P1","Todo","Release 1","release:r1;epic:weekoverzicht;type:logic","ST-501","Gemiddelde energie en budget-adherence zijn herleidbaar en testbaar."
|
||||||
|
"ST-503","Skip-patronen zichtbaar maken","Toon patronen rond skip-redenen en terugkerende activiteiten.","Story","EPIC-06","P1","Todo","Release 1","release:r1;epic:weekoverzicht;type:logic-ui","ST-502","Patronen worden alleen bij voldoende data getoond."
|
||||||
|
"ST-504","Insightregels met datadrempels definiëren","Leg guardrails vast voor het tonen van patronen.","Story","EPIC-06","P1","Todo","Release 1","release:r1;epic:weekoverzicht;type:safety-logic","ST-502","Geen patroonclaim zonder guardrails."
|
||||||
|
"ST-505","Insightcopy toetsen op niet-medische formulering","Controleer alle inzichtteksten op wellness-positionering.","Story","EPIC-06","P1","Todo","Release 1","release:r1;epic:weekoverzicht;type:content","ST-504","Alle teksten blijven binnen wellness-positionering."
|
||||||
|
"EPIC-07","Reflectie en reminders","Optionele T+1/T+2 follow-up mogelijk maken.","Epic","","P1","Todo","Release 1","release:r1;domain:reminders","EPIC-05",""
|
||||||
|
"ST-601","ReflectionCheckIn-model en flow implementeren","Implementeer model en basisflow voor reflectie na een zwaardere dag.","Story","EPIC-07","P1","Todo","Release 1","release:r1;epic:reflectie;type:build","EPIC-05","Reflecties kunnen aan eerdere dagen gekoppeld worden."
|
||||||
|
"ST-602","Joblogica voor T+1/T+2 prompts bouwen","Bepaal server-side welke gebruikers een reflectieprompt moeten zien.","Story","EPIC-07","P1","Todo","Release 1","release:r1;epic:reflectie;type:logic-ops","ST-601","Prompts worden niet dubbel of willekeurig aangemaakt."
|
||||||
|
"ST-603","Instellingsoptie voor reflectieprompts toevoegen","Maak opt-in beheerbaar vanuit instellingen.","Story","EPIC-07","P1","Todo","Release 1","release:r1;epic:reflectie;type:build","ST-104","Gebruiker beheert opt-in zelfstandig."
|
||||||
|
"ST-604","Korte reflectie-UI bouwen","Bouw een lichte, niet-medische reflectieprompt.","Story","EPIC-07","P1","Todo","Release 1","release:r1;epic:reflectie;type:ui","ST-602","Prompt voelt licht en niet medisch."
|
||||||
|
"EPIC-08","Security en operations","Logging, hardening, back-up en policy-tests.","Epic","","P0","Todo","Release 1","release:r1;domain:security-ops","EPIC-01,EPIC-02,EPIC-03,EPIC-04,EPIC-05,EPIC-06,EPIC-07",""
|
||||||
|
"ST-701","Rate limiting toevoegen","Bescherm kritieke auth- en mutatieroutes tegen misbruik.","Story","EPIC-08","P0","Todo","Release 1","release:r1;epic:security-ops;type:security","EPIC-02","Kritieke auth- en mutatieroutes zijn beschermd."
|
||||||
|
"ST-702","Logging voor fouten en kernmutaties inrichten","Log fouten, loginproblemen en belangrijke mutaties centraal.","Story","EPIC-08","P0","Todo","Release 1","release:r1;epic:security-ops;type:ops","EPIC-03,EPIC-04,EPIC-05","Kerngebeurtenissen zijn herleidbaar."
|
||||||
|
"ST-703","Back-up en herstelstrategie documenteren en testen","Werk het restore-pad uit en valideer het.","Story","EPIC-08","P0","Todo","Release 1","release:r1;epic:security-ops;type:ops","EPIC-01","Restore-pad is aantoonbaar gevalideerd."
|
||||||
|
"ST-704","Secrets- en environmentbeheer formaliseren","Leg veilig beheer van secrets en omgevingen vast voor Vercel en Supabase.","Story","EPIC-08","P0","Todo","Release 1","release:r1;epic:security-ops;type:security-ops","EPIC-01","Geen secrets in code of onveilige configuratie."
|
||||||
|
"ST-705","RLS-policy tests toevoegen","Test aantoonbaar dat owner-only toegang technisch afgedwongen is.","Story","EPIC-08","P0","Todo","Release 1","release:r1;epic:security-ops;type:qa-security","ST-105","Owner-only model is aantoonbaar afgedwongen."
|
||||||
|
"EPIC-09","Launch-readiness","QA, copy review, DPIA-input en go-live checks afronden.","Epic","","P0","Todo","Release 1","release:r1;domain:launch","EPIC-01,EPIC-02,EPIC-03,EPIC-04,EPIC-05,EPIC-06,EPIC-07,EPIC-08",""
|
||||||
|
"ST-801","Kernflows handmatig testen","Voer end-to-end handmatige tests uit op mobiel en desktop.","Story","EPIC-09","P0","Todo","Release 1","release:r1;epic:launch;type:qa","EPIC-05,EPIC-06,EPIC-07","Belangrijkste user journeys zijn geverifieerd."
|
||||||
|
"ST-802","Accessibility check uitvoeren","Controleer touch targets, contrast en reduced motion.","Story","EPIC-09","P0","Todo","Release 1","release:r1;epic:launch;type:qa-ux","EPIC-05","Touch targets, contrast en reduced motion zijn gecontroleerd."
|
||||||
|
"ST-803","Copy review doen","Controleer onboarding, dashboardteksten en inzichten op wellness-copy.","Story","EPIC-09","P0","Todo","Release 1","release:r1;epic:launch;type:content-safety","EPIC-06","Geen medische of zorgdossier-taal in release 1."
|
||||||
|
"ST-804","DPIA-input en datacatalogus afronden","Rond privacyartefacten af op basis van de werkelijke MVP-scope.","Story","EPIC-09","P0","Todo","Release 1","release:r1;epic:launch;type:privacy","EPIC-08","Pre-launch privacyartefacten zijn gereed."
|
||||||
|
"ST-805","Go-live checklist opstellen","Maak een checklist voor launch, rollback, monitoring en incidentrespons.","Story","EPIC-09","P0","Todo","Release 1","release:r1;epic:launch;type:ops","EPIC-08","Team weet hoe launch en eerste incidentrespons verloopt."
|
||||||
|
160
docs/backlog/inspannings-monitor-backlog.md
Normal file
160
docs/backlog/inspannings-monitor-backlog.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Inspannings Monitor Backlog
|
||||||
|
|
||||||
|
Dit bestand zet de huidige documentatieset om naar een concrete development backlog voor `release 1`.
|
||||||
|
|
||||||
|
## Uitgangspunten
|
||||||
|
|
||||||
|
- Productnaam: `Inspannings Monitor`
|
||||||
|
- Positionering: `wellness/self-management`
|
||||||
|
- Release 1: alleen individuele gebruikers
|
||||||
|
- Doelgroep: volwassenen
|
||||||
|
- Voertaal release 1: Nederlands
|
||||||
|
- Stack: `Vercel + Supabase Auth + Supabase PostgreSQL`
|
||||||
|
- Buiten scope release 1: sharing, AI, PDF-export, medische claims, habit-tracking buiten slaapkwaliteit
|
||||||
|
|
||||||
|
## Aanbevolen bouwvolgorde
|
||||||
|
|
||||||
|
1. Fundament en projectopzet
|
||||||
|
2. Authenticatie, profiel en instellingen
|
||||||
|
3. Ochtendcheck-in en budgetlogica
|
||||||
|
4. Activiteiten plannen
|
||||||
|
5. Activiteiten evalueren en dagoverzicht
|
||||||
|
6. Weekoverzicht en inzichten
|
||||||
|
7. Reflectieprompts en geplande taken
|
||||||
|
8. Privacy, security, logging en launch-readiness
|
||||||
|
|
||||||
|
## Epic-overzicht
|
||||||
|
|
||||||
|
| Epic | Titel | Prioriteit | Afhankelijk van | Doel |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| EPIC-01 | Fundament | P0 | - | Projectbasis, omgevingen en design foundation neerzetten |
|
||||||
|
| EPIC-02 | Auth en profiel | P0 | EPIC-01 | Inloggen, sessies, profiel en basisinstellingen |
|
||||||
|
| EPIC-03 | Ochtendcheck-in | P0 | EPIC-02 | Energiescore, slaapkwaliteit en dagbudget |
|
||||||
|
| EPIC-04 | Dagplanning | P0 | EPIC-03 | Activiteiten plannen en budgetfeedback tonen |
|
||||||
|
| EPIC-05 | Evaluatie en dagoverzicht | P0 | EPIC-04 | Activiteiten afronden en dagresultaat tonen |
|
||||||
|
| EPIC-06 | Weekoverzicht en inzichten | P1 | EPIC-05 | Weekpatronen en veilige insightregels |
|
||||||
|
| EPIC-07 | Reflectie en reminders | P1 | EPIC-05 | Optionele T+1/T+2 follow-up |
|
||||||
|
| EPIC-08 | Security en operations | P0 | EPIC-01 t/m EPIC-07 | Logging, hardening, back-up en policy-tests |
|
||||||
|
| EPIC-09 | Launch-readiness | P0 | EPIC-01 t/m EPIC-08 | QA, copy review, DPIA-input en go-live checks |
|
||||||
|
|
||||||
|
## EPIC-01 Fundament
|
||||||
|
|
||||||
|
Doel: een stabiele technische basis waarop alle kernflows kunnen landen.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-001 | Next.js projectbasis opzetten | Build | Project start lokaal en in preview zonder handmatige workarounds |
|
||||||
|
| ST-002 | Omgevingen definiëren | Ops | Development, preview en production zijn technisch ingericht |
|
||||||
|
| ST-003 | Component foundation neerzetten | UI | Herbruikbare basiscomponenten zijn mobiel bruikbaar |
|
||||||
|
| ST-004 | Foutafhandeling en lege staten ontwerpen | UX | Gebruiker krijgt bruikbare feedback bij lege of foutieve situaties |
|
||||||
|
|
||||||
|
## EPIC-02 Auth en profiel
|
||||||
|
|
||||||
|
Doel: iedere gebruiker kan veilig een eigen account en basisinstellingen beheren.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-101 | Supabase Auth integreren | Build | Gebruiker kan inloggen en beveiligde routes gebruiken |
|
||||||
|
| ST-102 | Profile- en UserSettings-model implementeren | Build | Profiel en instellingen zijn per gebruiker beschikbaar |
|
||||||
|
| ST-103 | Onboardingflow bouwen | UX | Nieuwe gebruiker begrijpt schaal, positionering en basisinstellingen |
|
||||||
|
| ST-104 | Settingsscherm bouwen | Build | Taal, timezone, reminders en zichtbaarheid van punten zijn persistent |
|
||||||
|
| ST-105 | RLS-basispolicies inrichten | Security | Gebruiker kan uitsluitend eigen profiel en settings lezen of wijzigen |
|
||||||
|
|
||||||
|
## EPIC-03 Ochtendcheck-in
|
||||||
|
|
||||||
|
Doel: de gebruiker kan met minimale inspanning de dag starten en een budget krijgen.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-201 | EnergySlider en SleepQualityInput bouwen | UI | Check-in kan mobiel comfortabel worden ingevuld |
|
||||||
|
| ST-202 | Server action voor createMorningCheckIn | Build | Check-in wordt opgeslagen met juiste validatie |
|
||||||
|
| ST-203 | Budgetlogica implementeren | Logic | Score mapping en budgetberekening zijn consistent en testbaar |
|
||||||
|
| ST-204 | Check-instatus op dashboard tonen | UI | Gebruiker ziet direct score, niveau en budget |
|
||||||
|
| ST-205 | Unit tests voor score- en budgetmapping | QA | Belangrijkste grenswaarden zijn afgedekt |
|
||||||
|
|
||||||
|
## EPIC-04 Dagplanning
|
||||||
|
|
||||||
|
Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-301 | Datamodel voor activiteiten implementeren | Build | Migraties en seed-data voor categorieën en skip-redenen zijn aanwezig |
|
||||||
|
| ST-302 | Planningformulier bouwen | UI | Activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt |
|
||||||
|
| ST-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Veelgebruikte activiteiten zijn snel opnieuw te kiezen |
|
||||||
|
| ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging |
|
||||||
|
| ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Gebruiker krijgt feedback maar behoudt regie |
|
||||||
|
|
||||||
|
## EPIC-05 Evaluatie en dagoverzicht
|
||||||
|
|
||||||
|
Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Alle drie de statussen worden correct opgeslagen |
|
||||||
|
| ST-402 | Evaluatievelden toevoegen | UI | Contextuele velden verschijnen passend per status |
|
||||||
|
| ST-403 | Ongeplande activiteiten ondersteunen | Build | Ongeplande activiteit telt mee in werkelijke totalen |
|
||||||
|
| ST-404 | Dagoverzicht bouwen | UI | Gepland versus uitgevoerd en statusverdeling zijn zichtbaar |
|
||||||
|
| ST-405 | Dagaggregaties server-side implementeren | Logic | Dagtotalen blijven consistent met individuele records |
|
||||||
|
|
||||||
|
## EPIC-06 Weekoverzicht en inzichten
|
||||||
|
|
||||||
|
Doel: terugkijken op patronen zonder de wellness-guardrails te verlaten.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-501 | Weekoverzichtspagina bouwen | UI | Gebruiker kan per week terugkijken |
|
||||||
|
| ST-502 | Weekaggregaties bouwen | Logic | Gemiddelde energie en budget-adherence zijn herleidbaar en testbaar |
|
||||||
|
| ST-503 | Skip-patronen zichtbaar maken | Logic/UI | Patronen worden alleen bij voldoende data getoond |
|
||||||
|
| ST-504 | Insightregels met datadrempels definiëren | Safety/Logic | Geen patroonclaim zonder guardrails |
|
||||||
|
| ST-505 | Insightcopy toetsen op niet-medische formulering | Content | Alle teksten blijven binnen wellness-positionering |
|
||||||
|
|
||||||
|
## EPIC-07 Reflectie en reminders
|
||||||
|
|
||||||
|
Doel: gebruikers optioneel laten terugblikken na zwaardere dagen.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-601 | ReflectionCheckIn-model en flow implementeren | Build | Reflecties kunnen aan eerdere dagen gekoppeld worden |
|
||||||
|
| ST-602 | Joblogica voor T+1/T+2 prompts bouwen | Logic/Ops | Prompts worden niet dubbel of willekeurig aangemaakt |
|
||||||
|
| ST-603 | Instellingsoptie voor reflectieprompts toevoegen | Build | Gebruiker beheert opt-in zelfstandig |
|
||||||
|
| ST-604 | Korte reflectie-UI bouwen | UI | Prompt voelt licht en niet medisch |
|
||||||
|
|
||||||
|
## EPIC-08 Security en operations
|
||||||
|
|
||||||
|
Doel: de wellness-first MVP technisch hard genoeg maken voor echte gebruikers.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-701 | Rate limiting toevoegen | Security | Kritieke auth- en mutatieroutes zijn beschermd |
|
||||||
|
| ST-702 | Logging voor fouten en kernmutaties inrichten | Ops | Kerngebeurtenissen zijn herleidbaar |
|
||||||
|
| ST-703 | Back-up en herstelstrategie documenteren en testen | Ops | Restore-pad is aantoonbaar gevalideerd |
|
||||||
|
| ST-704 | Secrets- en environmentbeheer formaliseren | Security/Ops | Geen secrets in code of onveilige configuratie |
|
||||||
|
| ST-705 | RLS-policy tests toevoegen | QA/Security | Owner-only model is aantoonbaar afgedwongen |
|
||||||
|
|
||||||
|
## EPIC-09 Launch-readiness
|
||||||
|
|
||||||
|
Doel: release 1 verantwoord kunnen opleveren.
|
||||||
|
|
||||||
|
| Story ID | Titel | Type | Definition of done |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ST-801 | Kernflows handmatig testen | QA | Belangrijkste user journeys zijn geverifieerd |
|
||||||
|
| ST-802 | Accessibility check uitvoeren | QA/UX | Touch targets, contrast en reduced motion zijn gecontroleerd |
|
||||||
|
| ST-803 | Copy review doen | Content/Safety | Geen medische of zorgdossier-taal in release 1 |
|
||||||
|
| ST-804 | DPIA-input en datacatalogus afronden | Privacy | Pre-launch privacyartefacten zijn gereed |
|
||||||
|
| ST-805 | Go-live checklist opstellen | Ops | Team weet hoe launch en eerste incidentrespons verloopt |
|
||||||
|
|
||||||
|
## Release-level definition of done
|
||||||
|
|
||||||
|
- Alle `P0`-epics zijn functioneel afgerond
|
||||||
|
- Geen blocking bugs in ochtendcheck-in, planning, evaluatie of dashboardflow
|
||||||
|
- Owner-only toegang is technisch afgedwongen en getest
|
||||||
|
- Launchcopy blijft binnen wellness/self-management claims
|
||||||
|
- Privacy- en securitybasis is gereed voor echte gebruikersintroductie
|
||||||
|
|
||||||
|
## Niet in release 1
|
||||||
|
|
||||||
|
- Viewerrollen, delen met zorgverleners of naasten, en granular sharing
|
||||||
|
- Habit tracking buiten slaapkwaliteit
|
||||||
|
- Database-gestuurde vertalingen of extra talen
|
||||||
|
- AI-inzichten, chatbotfuncties of vrije tekstinterpretatie
|
||||||
|
- PDF-export, zorgdossierkoppelingen of medical-track features
|
||||||
110
docs/backlog/inspannings-monitor-linear-eerste-30-minuten.md
Normal file
110
docs/backlog/inspannings-monitor-linear-eerste-30-minuten.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Inspannings Monitor: eerste 30 minuten in Linear
|
||||||
|
|
||||||
|
Gebruik dit als snelle start nadat je workspace klaar is.
|
||||||
|
|
||||||
|
## 0-5 minuten: basis neerzetten
|
||||||
|
|
||||||
|
- Open of maak de workspace `Inspannings Monitor`
|
||||||
|
- Controleer dat je adminrechten hebt
|
||||||
|
- Maak één team aan: `Inspannings Monitor`
|
||||||
|
- Laat de issueworkflow simpel:
|
||||||
|
- `Backlog`
|
||||||
|
- `Todo`
|
||||||
|
- `In Progress`
|
||||||
|
- `Done`
|
||||||
|
- `Canceled`
|
||||||
|
- Zet `Cycles` nog niet aan
|
||||||
|
|
||||||
|
## 5-10 minuten: release-structuur maken
|
||||||
|
|
||||||
|
- Maak de initiative `Release 1 MVP`
|
||||||
|
- Maak de 9 projects uit [inspannings-monitor-linear-projects.md](./inspannings-monitor-linear-projects.md)
|
||||||
|
- Gebruik de samenvattingen uit dat document
|
||||||
|
- Zet deze projects direct op `Planned`:
|
||||||
|
- `Fundament`
|
||||||
|
- `Auth en profiel`
|
||||||
|
- `Ochtendcheck-in`
|
||||||
|
- `Dagplanning`
|
||||||
|
- `Evaluatie en dagoverzicht`
|
||||||
|
- `Security en operations`
|
||||||
|
- Zet deze op `Backlog`:
|
||||||
|
- `Weekoverzicht en inzichten`
|
||||||
|
- `Reflectie en reminders`
|
||||||
|
- `Launch-readiness`
|
||||||
|
|
||||||
|
## 10-15 minuten: labels klaarzetten
|
||||||
|
|
||||||
|
Maak minimaal deze labels aan:
|
||||||
|
|
||||||
|
- `release:r1`
|
||||||
|
- `type:build`
|
||||||
|
- `type:ui`
|
||||||
|
- `type:logic`
|
||||||
|
- `type:qa`
|
||||||
|
- `type:security`
|
||||||
|
- `type:ops`
|
||||||
|
- `type:ux`
|
||||||
|
- `type:content`
|
||||||
|
- `type:privacy`
|
||||||
|
|
||||||
|
Maak daarna de `epic:*` labels uit [inspannings-monitor-linear-setup.md](./inspannings-monitor-linear-setup.md).
|
||||||
|
|
||||||
|
## 15-20 minuten: backlog importeren
|
||||||
|
|
||||||
|
- Open [inspannings-monitor-linear-import-checklist.md](./inspannings-monitor-linear-import-checklist.md)
|
||||||
|
- Gebruik [inspannings-monitor-linear-import-issues.csv](./inspannings-monitor-linear-import-issues.csv)
|
||||||
|
- Importeer de `43` issues
|
||||||
|
- Controleer direct:
|
||||||
|
- team klopt
|
||||||
|
- status is `Backlog`
|
||||||
|
- prioriteit is `high` of `medium`
|
||||||
|
- labels zijn meegekomen
|
||||||
|
|
||||||
|
## 20-25 minuten: eerste opschoning
|
||||||
|
|
||||||
|
- Controleer of issues aan de juiste projects hangen
|
||||||
|
- Als projectkoppeling ontbreekt:
|
||||||
|
- filter op `epic:*` label
|
||||||
|
- koppel issues in bulk aan het juiste project
|
||||||
|
- Koppel daarna alles aan initiative `Release 1 MVP` als dat nog niet goed staat
|
||||||
|
- Sorteer de backlog op `Priority`
|
||||||
|
|
||||||
|
## 25-30 minuten: eerste views maken
|
||||||
|
|
||||||
|
Maak deze saved views:
|
||||||
|
|
||||||
|
- `Release 1 - All`
|
||||||
|
- `P0`
|
||||||
|
- `Security / Privacy`
|
||||||
|
- `Launch-readiness`
|
||||||
|
|
||||||
|
Als je daarna nog tijd hebt:
|
||||||
|
|
||||||
|
- wijs 2 of 3 issues uit `Fundament` toe
|
||||||
|
- zet alleen die issues op `Todo`
|
||||||
|
- laat de rest nog in `Backlog`
|
||||||
|
|
||||||
|
## Wat je nog niet moet doen
|
||||||
|
|
||||||
|
- nog geen cycles aanzetten
|
||||||
|
- nog geen extra workflowstappen maken
|
||||||
|
- nog geen sub-teams maken
|
||||||
|
- nog geen extra releasestructuur toevoegen
|
||||||
|
- nog geen medical-track werk mengen met release 1
|
||||||
|
|
||||||
|
## Beste eerste werkset
|
||||||
|
|
||||||
|
Als je meteen wilt starten met uitvoering, begin dan hier:
|
||||||
|
|
||||||
|
1. `ST-001` Next.js projectbasis opzetten
|
||||||
|
2. `ST-002` Omgevingen definiëren
|
||||||
|
3. `ST-101` Supabase Auth integreren
|
||||||
|
4. `ST-102` Profile- en UserSettings-model implementeren
|
||||||
|
|
||||||
|
## Relevante bestanden
|
||||||
|
|
||||||
|
- [inspannings-monitor-linear-setup.md](./inspannings-monitor-linear-setup.md)
|
||||||
|
- [inspannings-monitor-linear-import-checklist.md](./inspannings-monitor-linear-import-checklist.md)
|
||||||
|
- [inspannings-monitor-linear-projects.md](./inspannings-monitor-linear-projects.md)
|
||||||
|
- [inspannings-monitor-linear-import-issues.csv](./inspannings-monitor-linear-import-issues.csv)
|
||||||
|
- [inspannings-monitor-backlog.md](./inspannings-monitor-backlog.md)
|
||||||
185
docs/backlog/inspannings-monitor-linear-import-checklist.md
Normal file
185
docs/backlog/inspannings-monitor-linear-import-checklist.md
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Inspannings Monitor Linear Import Checklist
|
||||||
|
|
||||||
|
Gebruik deze checklist om de backlog en projectstructuur van `Inspannings Monitor` gecontroleerd in `Linear` te krijgen.
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Na afronding van deze checklist heb je:
|
||||||
|
|
||||||
|
- één `Linear` workspace
|
||||||
|
- één team voor `release 1`
|
||||||
|
- één initiative voor de MVP
|
||||||
|
- negen projects voor de epics
|
||||||
|
- een geïmporteerde issue-backlog voor alle stories
|
||||||
|
- een eerste kwaliteitscontrole op mapping, labels en prioriteiten
|
||||||
|
|
||||||
|
## Benodigde bestanden
|
||||||
|
|
||||||
|
- [inspannings-monitor-linear-setup.md](./inspannings-monitor-linear-setup.md)
|
||||||
|
- [inspannings-monitor-linear-import-issues.csv](./inspannings-monitor-linear-import-issues.csv)
|
||||||
|
- [inspannings-monitor-linear-projects.csv](./inspannings-monitor-linear-projects.csv)
|
||||||
|
- [inspannings-monitor-linear-projects.md](./inspannings-monitor-linear-projects.md)
|
||||||
|
- [inspannings-monitor-backlog.md](./inspannings-monitor-backlog.md)
|
||||||
|
|
||||||
|
## Fase 1: Workspace voorbereiden
|
||||||
|
|
||||||
|
- [ ] Maak of kies de `Linear` workspace voor `Inspannings Monitor`
|
||||||
|
- [ ] Bevestig dat je adminrechten hebt in de workspace
|
||||||
|
- [ ] Maak één top-level team aan met naam `Inspannings Monitor`
|
||||||
|
- [ ] Laat de teamworkflow in het begin simpel:
|
||||||
|
- `Backlog`
|
||||||
|
- `Todo`
|
||||||
|
- `In Progress`
|
||||||
|
- `Done`
|
||||||
|
- `Canceled`
|
||||||
|
- [ ] Zet `Cycles` nog niet aan
|
||||||
|
- [ ] Gebruik nog geen extra sub-teams
|
||||||
|
|
||||||
|
## Fase 2: Labels voorbereiden
|
||||||
|
|
||||||
|
Maak deze labels aan als teamlabels:
|
||||||
|
|
||||||
|
- [ ] `release:r1`
|
||||||
|
- [ ] `type:build`
|
||||||
|
- [ ] `type:ui`
|
||||||
|
- [ ] `type:logic`
|
||||||
|
- [ ] `type:qa`
|
||||||
|
- [ ] `type:security`
|
||||||
|
- [ ] `type:ops`
|
||||||
|
- [ ] `type:ux`
|
||||||
|
- [ ] `type:content`
|
||||||
|
- [ ] `type:privacy`
|
||||||
|
- [ ] `epic:fundament`
|
||||||
|
- [ ] `epic:auth-profiel`
|
||||||
|
- [ ] `epic:ochtendcheckin`
|
||||||
|
- [ ] `epic:dagplanning`
|
||||||
|
- [ ] `epic:evaluatie`
|
||||||
|
- [ ] `epic:weekoverzicht`
|
||||||
|
- [ ] `epic:reflectie`
|
||||||
|
- [ ] `epic:security-ops`
|
||||||
|
- [ ] `epic:launch`
|
||||||
|
|
||||||
|
## Fase 3: Initiative en projects aanmaken
|
||||||
|
|
||||||
|
### Initiative
|
||||||
|
|
||||||
|
- [ ] Maak één initiative aan: `Release 1 MVP`
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Maak deze negen projects aan:
|
||||||
|
|
||||||
|
- [ ] `Fundament`
|
||||||
|
- [ ] `Auth en profiel`
|
||||||
|
- [ ] `Ochtendcheck-in`
|
||||||
|
- [ ] `Dagplanning`
|
||||||
|
- [ ] `Evaluatie en dagoverzicht`
|
||||||
|
- [ ] `Weekoverzicht en inzichten`
|
||||||
|
- [ ] `Reflectie en reminders`
|
||||||
|
- [ ] `Security en operations`
|
||||||
|
- [ ] `Launch-readiness`
|
||||||
|
|
||||||
|
### Aanbevolen projectleads
|
||||||
|
|
||||||
|
- [ ] Wijs alleen projectleads toe als die nu al duidelijk zijn
|
||||||
|
- [ ] Laat anders de leads leeg bij de eerste importfase
|
||||||
|
|
||||||
|
## Fase 4: Import voorbereiden
|
||||||
|
|
||||||
|
- [ ] Open de actuele import-CSV: [inspannings-monitor-linear-import-issues.csv](./inspannings-monitor-linear-import-issues.csv)
|
||||||
|
- [ ] Controleer steekproefsgewijs:
|
||||||
|
- `Team` = `Inspannings Monitor`
|
||||||
|
- `Status` = `Backlog`
|
||||||
|
- `Priority` = `high` of `medium`
|
||||||
|
- `Project` bevat één van de negen epic-projectnamen
|
||||||
|
- `Initiatives` = `Release 1 MVP`
|
||||||
|
- [ ] Maak desnoods eerst een testworkspace of testteam als je Linear eerst wilt proefimporteren
|
||||||
|
|
||||||
|
## Fase 5: Import uitvoeren
|
||||||
|
|
||||||
|
Volgens de actuele `Linear`-documentatie loopt import van “other” bronnen via hun importer/CLI met een CSV in `Linear`-formaat.
|
||||||
|
|
||||||
|
- [ ] Ga in Linear naar `Settings > Administration > Import/Export`
|
||||||
|
- [ ] Kies de importroute voor een generieke / `Other`-bron of `Linear CSV`-aanpak
|
||||||
|
- [ ] Upload of importeer het bestand `inspannings-monitor-linear-import-issues.csv`
|
||||||
|
- [ ] Gebruik het team `Inspannings Monitor` als doel
|
||||||
|
- [ ] Rond de import af
|
||||||
|
|
||||||
|
## Fase 6: Directe controle na import
|
||||||
|
|
||||||
|
Controleer meteen deze punten:
|
||||||
|
|
||||||
|
- [ ] Zijn alle `43` issues aanwezig?
|
||||||
|
- [ ] Staan de issues in het team `Inspannings Monitor`?
|
||||||
|
- [ ] Hebben issues status `Backlog`?
|
||||||
|
- [ ] Zijn prioriteiten zichtbaar als `high` of `medium`?
|
||||||
|
- [ ] Zijn labels meegekomen?
|
||||||
|
- [ ] Zijn issues aan de juiste projects gekoppeld?
|
||||||
|
- [ ] Is de initiative-koppeling zichtbaar?
|
||||||
|
|
||||||
|
## Fase 7: Als project- of initiative-koppeling niet goed is meegekomen
|
||||||
|
|
||||||
|
Gebruik dan deze fallback:
|
||||||
|
|
||||||
|
- [ ] Filter op label `epic:fundament` en koppel alle resultaten in bulk aan project `Fundament`
|
||||||
|
- [ ] Filter op label `epic:auth-profiel` en koppel in bulk aan `Auth en profiel`
|
||||||
|
- [ ] Filter op label `epic:ochtendcheckin` en koppel in bulk aan `Ochtendcheck-in`
|
||||||
|
- [ ] Filter op label `epic:dagplanning` en koppel in bulk aan `Dagplanning`
|
||||||
|
- [ ] Filter op label `epic:evaluatie` en koppel in bulk aan `Evaluatie en dagoverzicht`
|
||||||
|
- [ ] Filter op label `epic:weekoverzicht` en koppel in bulk aan `Weekoverzicht en inzichten`
|
||||||
|
- [ ] Filter op label `epic:reflectie` en koppel in bulk aan `Reflectie en reminders`
|
||||||
|
- [ ] Filter op label `epic:security-ops` en koppel in bulk aan `Security en operations`
|
||||||
|
- [ ] Filter op label `epic:launch` en koppel in bulk aan `Launch-readiness`
|
||||||
|
- [ ] Selecteer daarna alle release-1 issues en koppel ze in bulk aan initiative `Release 1 MVP`
|
||||||
|
|
||||||
|
## Fase 8: Eerste opschoning in Linear
|
||||||
|
|
||||||
|
- [ ] Sorteer backlog eerst op `Priority`
|
||||||
|
- [ ] Controleer of alle `P0`-stories als `high` binnengekomen zijn
|
||||||
|
- [ ] Controleer of alle `P1`-stories als `medium` binnengekomen zijn
|
||||||
|
- [ ] Archiveer nog niets in deze eerste fase
|
||||||
|
- [ ] Voeg nog geen cycles toe
|
||||||
|
- [ ] Voeg nog geen extra workflowstappen toe
|
||||||
|
|
||||||
|
## Fase 9: Eerste operationele inrichting
|
||||||
|
|
||||||
|
- [ ] Maak een opgeslagen view voor `Release 1 - All`
|
||||||
|
- [ ] Maak een view voor `P0`
|
||||||
|
- [ ] Maak een view voor `Security / Privacy`
|
||||||
|
- [ ] Maak een view voor `Launch-readiness`
|
||||||
|
- [ ] Maak eventueel één view `My work` of `This week`
|
||||||
|
|
||||||
|
## Fase 10: Go / No-Go na import
|
||||||
|
|
||||||
|
### Go als dit klopt
|
||||||
|
|
||||||
|
- [ ] Alle `43` issues staan in Linear
|
||||||
|
- [ ] De negen projects bestaan
|
||||||
|
- [ ] De initiative bestaat
|
||||||
|
- [ ] Labels en prioriteiten zijn bruikbaar
|
||||||
|
- [ ] De backlog is zonder extra handwerk te filteren per epic
|
||||||
|
|
||||||
|
### No-Go als dit misgaat
|
||||||
|
|
||||||
|
- [ ] Issues missen of zijn dubbel geïmporteerd
|
||||||
|
- [ ] Projectkoppeling is structureel kapot
|
||||||
|
- [ ] Statusmapping is onbruikbaar
|
||||||
|
- [ ] CSV blijkt niet goed te matchen met de importer
|
||||||
|
|
||||||
|
Als `No-Go`: import verwijderen, mapping aanpassen, en opnieuw importeren.
|
||||||
|
|
||||||
|
## Eerste week in Linear
|
||||||
|
|
||||||
|
Voor de eerste week zou ik dit simpel houden:
|
||||||
|
|
||||||
|
- gebruik alleen `Backlog`, `Todo`, `In Progress`, `Done`
|
||||||
|
- plan nog geen formele cycles
|
||||||
|
- werk eerst `EPIC-01` en `EPIC-02` uit
|
||||||
|
- gebruik labels en projects voor overzicht, niet extra workflowcomplexiteit
|
||||||
|
|
||||||
|
## Bronnen
|
||||||
|
|
||||||
|
- [Linear Importer docs](https://linear.app/docs/import-issues)
|
||||||
|
- [Linear Teams docs](https://linear.app/docs/teams)
|
||||||
|
- [Linear Projects docs](https://linear.app/docs/projects)
|
||||||
|
- [Linear Priority docs](https://linear.app/docs/priority)
|
||||||
431
docs/backlog/inspannings-monitor-linear-import-issues.csv
Normal file
431
docs/backlog/inspannings-monitor-linear-import-issues.csv
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Created,Updated,Started,Triaged,Completed,Canceled,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status
|
||||||
|
,Inspannings Monitor,Next.js projectbasis opzetten,"Zet de projectbasis op met TypeScript en de gekozen stylingaanpak.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-001`
|
||||||
|
- Epic / project: `Fundament`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-01`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Project start lokaal en in preview zonder handmatige workarounds.",Backlog,,high,,Fundament,,,"release:r1, epic:fundament, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Omgevingen definiëren,"Richt development, preview en production technisch in.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-002`
|
||||||
|
- Epic / project: `Fundament`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-001`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Development, preview en production zijn technisch ingericht.",Backlog,,high,,Fundament,,,"release:r1, epic:fundament, type:ops",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Component foundation neerzetten,"Bouw herbruikbare basiscomponenten voor formulieren, kaarten, knoppen en meldingen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-003`
|
||||||
|
- Epic / project: `Fundament`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-001`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Herbruikbare basiscomponenten zijn mobiel bruikbaar.",Backlog,,high,,Fundament,,,"release:r1, epic:fundament, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Foutafhandeling en lege staten ontwerpen,"Ontwerp en implementeer lege staten en bruikbare foutfeedback.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-004`
|
||||||
|
- Epic / project: `Fundament`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-001`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker krijgt bruikbare feedback bij lege of foutieve situaties.",Backlog,,high,,Fundament,,,"release:r1, epic:fundament, type:ux",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Supabase Auth integreren,"Integreer Supabase Auth en de sessieflow in de app.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-101`
|
||||||
|
- Epic / project: `Auth en profiel`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-01`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker kan inloggen en beveiligde routes gebruiken.",Backlog,,high,,Auth en profiel,,,"release:r1, epic:auth-profiel, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Profile- en UserSettings-model implementeren,"Implementeer profiel- en settingsmodellen per gebruiker.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-102`
|
||||||
|
- Epic / project: `Auth en profiel`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-101`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Profiel en instellingen zijn per gebruiker beschikbaar.",Backlog,,high,,Auth en profiel,,,"release:r1, epic:auth-profiel, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Onboardingflow bouwen,"Bouw een onboarding van maximaal drie schermen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-103`
|
||||||
|
- Epic / project: `Auth en profiel`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-101`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Nieuwe gebruiker begrijpt schaal, positionering en basisinstellingen.",Backlog,,high,,Auth en profiel,,,"release:r1, epic:auth-profiel, type:ux",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Settingsscherm bouwen,"Bouw instellingen voor taal, timezone, reminders en zichtbaarheid van punten.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-104`
|
||||||
|
- Epic / project: `Auth en profiel`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-102`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Taal, timezone, reminders en zichtbaarheid van punten zijn persistent.",Backlog,,high,,Auth en profiel,,,"release:r1, epic:auth-profiel, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,RLS-basispolicies inrichten,"Richt owner-only RLS-policies in voor profiel en instellingen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-105`
|
||||||
|
- Epic / project: `Auth en profiel`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-101`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker kan uitsluitend eigen profiel en settings lezen of wijzigen.",Backlog,,high,,Auth en profiel,,,"release:r1, epic:auth-profiel, type:security",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,EnergySlider en SleepQualityInput bouwen,"Bouw de invoercomponenten voor energiescore en slaapkwaliteit.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-201`
|
||||||
|
- Epic / project: `Ochtendcheck-in`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-02`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Check-in kan mobiel comfortabel worden ingevuld.",Backlog,,high,,Ochtendcheck-in,,,"release:r1, epic:ochtendcheckin, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Server action voor createMorningCheckIn,"Implementeer de server action voor het opslaan van de ochtendcheck-in.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-202`
|
||||||
|
- Epic / project: `Ochtendcheck-in`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-201`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Check-in wordt opgeslagen met juiste validatie.",Backlog,,high,,Ochtendcheck-in,,,"release:r1, epic:ochtendcheckin, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Budgetlogica implementeren,"Bouw mapping van score naar energy level en dagbudget.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-203`
|
||||||
|
- Epic / project: `Ochtendcheck-in`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-202`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Score mapping en budgetberekening zijn consistent en testbaar.",Backlog,,high,,Ochtendcheck-in,,,"release:r1, epic:ochtendcheckin, type:logic",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Check-instatus op dashboard tonen,"Toon direct score, niveau en budget op het dashboard.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-204`
|
||||||
|
- Epic / project: `Ochtendcheck-in`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-202`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker ziet direct score, niveau en budget.",Backlog,,high,,Ochtendcheck-in,,,"release:r1, epic:ochtendcheckin, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Unit tests voor score- en budgetmapping,"Voeg tests toe voor grenswaarden en budgetberekening.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-205`
|
||||||
|
- Epic / project: `Ochtendcheck-in`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-203`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Belangrijkste grenswaarden zijn afgedekt.",Backlog,,high,,Ochtendcheck-in,,,"release:r1, epic:ochtendcheckin, type:qa",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Datamodel voor activiteiten implementeren,"Implementeer tabellen en seed-data voor activiteiten, categorieen en skip-redenen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-301`
|
||||||
|
- Epic / project: `Dagplanning`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-03`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Migraties en seed-data voor categorieen en skip-redenen zijn aanwezig.",Backlog,,high,,Dagplanning,,,"release:r1, epic:dagplanning, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Planningformulier bouwen,"Bouw het formulier voor naam, categorie, duur, impact en prioriteit.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-302`
|
||||||
|
- Epic / project: `Dagplanning`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-301`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt.",Backlog,,high,,Dagplanning,,,"release:r1, epic:dagplanning, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Autocomplete op eerdere activiteiten toevoegen,"Maak snelle herselectie van eerder gebruikte activiteiten mogelijk.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-303`
|
||||||
|
- Epic / project: `Dagplanning`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-302`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Veelgebruikte activiteiten zijn snel opnieuw te kiezen.",Backlog,,high,,Dagplanning,,,"release:r1, epic:dagplanning, type:ux",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,EnergyMeter en lopend totaal implementeren,"Toon het lopende totaal ten opzichte van het dagbudget.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-304`
|
||||||
|
- Epic / project: `Dagplanning`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-302`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Totaal update direct na elke wijziging.",Backlog,,high,,Dagplanning,,,"release:r1, epic:dagplanning, type:logic-ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Overschrijdingswaarschuwing toevoegen,"Toon een niet-blokkerende waarschuwing bij budgetoverschrijding.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-305`
|
||||||
|
- Epic / project: `Dagplanning`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-304`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker krijgt feedback maar behoudt regie.",Backlog,,high,,Dagplanning,,,"release:r1, epic:dagplanning, type:ux",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,"Statusflows voor uitgevoerd, geskipt en aangepast bouwen","Implementeer de drie kernstatussen voor activiteiten.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-401`
|
||||||
|
- Epic / project: `Evaluatie en dagoverzicht`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-04`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Alle drie de statussen worden correct opgeslagen.",Backlog,,high,,Evaluatie en dagoverzicht,,,"release:r1, epic:evaluatie, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Evaluatievelden toevoegen,"Voeg contextuele velden toe voor werkelijke duur, fatigue en skip-reden.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-402`
|
||||||
|
- Epic / project: `Evaluatie en dagoverzicht`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-401`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Contextuele velden verschijnen passend per status.",Backlog,,high,,Evaluatie en dagoverzicht,,,"release:r1, epic:evaluatie, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Ongeplande activiteiten ondersteunen,"Maak het mogelijk een ongeplande activiteit toe te voegen en mee te tellen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-403`
|
||||||
|
- Epic / project: `Evaluatie en dagoverzicht`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-401`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Ongeplande activiteit telt mee in werkelijke totalen.",Backlog,,high,,Evaluatie en dagoverzicht,,,"release:r1, epic:evaluatie, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Dagoverzicht bouwen,"Bouw het overzicht met gepland versus uitgevoerd en statusverdeling.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-404`
|
||||||
|
- Epic / project: `Evaluatie en dagoverzicht`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-401`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gepland versus uitgevoerd en statusverdeling zijn zichtbaar.",Backlog,,high,,Evaluatie en dagoverzicht,,,"release:r1, epic:evaluatie, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Dagaggregaties server-side implementeren,"Bereken dagtotalen en samenvatting server-side.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-405`
|
||||||
|
- Epic / project: `Evaluatie en dagoverzicht`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-404`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Dagtotalen blijven consistent met individuele records.",Backlog,,high,,Evaluatie en dagoverzicht,,,"release:r1, epic:evaluatie, type:logic",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Weekoverzichtspagina bouwen,"Bouw de pagina voor weekterugblik.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-501`
|
||||||
|
- Epic / project: `Weekoverzicht en inzichten`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-05`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker kan per week terugkijken.",Backlog,,medium,,Weekoverzicht en inzichten,,,"release:r1, epic:weekoverzicht, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Weekaggregaties bouwen,"Bereken gemiddelde energie en budget-adherence per week.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-502`
|
||||||
|
- Epic / project: `Weekoverzicht en inzichten`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-501`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gemiddelde energie en budget-adherence zijn herleidbaar en testbaar.",Backlog,,medium,,Weekoverzicht en inzichten,,,"release:r1, epic:weekoverzicht, type:logic",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Skip-patronen zichtbaar maken,"Toon patronen rond skip-redenen en terugkerende activiteiten.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-503`
|
||||||
|
- Epic / project: `Weekoverzicht en inzichten`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-502`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Patronen worden alleen bij voldoende data getoond.",Backlog,,medium,,Weekoverzicht en inzichten,,,"release:r1, epic:weekoverzicht, type:logic-ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Insightregels met datadrempels definiëren,"Leg guardrails vast voor het tonen van patronen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-504`
|
||||||
|
- Epic / project: `Weekoverzicht en inzichten`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-502`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Geen patroonclaim zonder guardrails.",Backlog,,medium,,Weekoverzicht en inzichten,,,"release:r1, epic:weekoverzicht, type:safety-logic",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Insightcopy toetsen op niet-medische formulering,"Controleer alle inzichtteksten op wellness-positionering.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-505`
|
||||||
|
- Epic / project: `Weekoverzicht en inzichten`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-504`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Alle teksten blijven binnen wellness-positionering.",Backlog,,medium,,Weekoverzicht en inzichten,,,"release:r1, epic:weekoverzicht, type:content",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,ReflectionCheckIn-model en flow implementeren,"Implementeer model en basisflow voor reflectie na een zwaardere dag.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-601`
|
||||||
|
- Epic / project: `Reflectie en reminders`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-05`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Reflecties kunnen aan eerdere dagen gekoppeld worden.",Backlog,,medium,,Reflectie en reminders,,,"release:r1, epic:reflectie, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Joblogica voor T+1/T+2 prompts bouwen,"Bepaal server-side welke gebruikers een reflectieprompt moeten zien.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-602`
|
||||||
|
- Epic / project: `Reflectie en reminders`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-601`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Prompts worden niet dubbel of willekeurig aangemaakt.",Backlog,,medium,,Reflectie en reminders,,,"release:r1, epic:reflectie, type:logic-ops",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Instellingsoptie voor reflectieprompts toevoegen,"Maak opt-in beheerbaar vanuit instellingen.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-603`
|
||||||
|
- Epic / project: `Reflectie en reminders`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-104`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Gebruiker beheert opt-in zelfstandig.",Backlog,,medium,,Reflectie en reminders,,,"release:r1, epic:reflectie, type:build",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Korte reflectie-UI bouwen,"Bouw een lichte, niet-medische reflectieprompt.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-604`
|
||||||
|
- Epic / project: `Reflectie en reminders`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-602`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Prompt voelt licht en niet medisch.",Backlog,,medium,,Reflectie en reminders,,,"release:r1, epic:reflectie, type:ui",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Rate limiting toevoegen,"Bescherm kritieke auth- en mutatieroutes tegen misbruik.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-701`
|
||||||
|
- Epic / project: `Security en operations`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-02`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Kritieke auth- en mutatieroutes zijn beschermd.",Backlog,,high,,Security en operations,,,"release:r1, epic:security-ops, type:security",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Logging voor fouten en kernmutaties inrichten,"Log fouten, loginproblemen en belangrijke mutaties centraal.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-702`
|
||||||
|
- Epic / project: `Security en operations`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-03,EPIC-04,EPIC-05`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Kerngebeurtenissen zijn herleidbaar.",Backlog,,high,,Security en operations,,,"release:r1, epic:security-ops, type:ops",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Back-up en herstelstrategie documenteren en testen,"Werk het restore-pad uit en valideer het.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-703`
|
||||||
|
- Epic / project: `Security en operations`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-01`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Restore-pad is aantoonbaar gevalideerd.",Backlog,,high,,Security en operations,,,"release:r1, epic:security-ops, type:ops",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Secrets- en environmentbeheer formaliseren,"Leg veilig beheer van secrets en omgevingen vast voor Vercel en Supabase.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-704`
|
||||||
|
- Epic / project: `Security en operations`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-01`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Geen secrets in code of onveilige configuratie.",Backlog,,high,,Security en operations,,,"release:r1, epic:security-ops, type:security-ops",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,RLS-policy tests toevoegen,"Test aantoonbaar dat owner-only toegang technisch afgedwongen is.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-705`
|
||||||
|
- Epic / project: `Security en operations`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `ST-105`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Owner-only model is aantoonbaar afgedwongen.",Backlog,,high,,Security en operations,,,"release:r1, epic:security-ops, type:qa-security",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Kernflows handmatig testen,"Voer end-to-end handmatige tests uit op mobiel en desktop.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-801`
|
||||||
|
- Epic / project: `Launch-readiness`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-05,EPIC-06,EPIC-07`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Belangrijkste user journeys zijn geverifieerd.",Backlog,,high,,Launch-readiness,,,"release:r1, epic:launch, type:qa",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Accessibility check uitvoeren,"Controleer touch targets, contrast en reduced motion.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-802`
|
||||||
|
- Epic / project: `Launch-readiness`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-05`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Touch targets, contrast en reduced motion zijn gecontroleerd.",Backlog,,high,,Launch-readiness,,,"release:r1, epic:launch, type:qa-ux",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Copy review doen,"Controleer onboarding, dashboardteksten en inzichten op wellness-copy.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-803`
|
||||||
|
- Epic / project: `Launch-readiness`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-06`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Geen medische of zorgdossier-taal in release 1.",Backlog,,high,,Launch-readiness,,,"release:r1, epic:launch, type:content-safety",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,DPIA-input en datacatalogus afronden,"Rond privacyartefacten af op basis van de werkelijke MVP-scope.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-804`
|
||||||
|
- Epic / project: `Launch-readiness`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-08`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Pre-launch privacyartefacten zijn gereed.",Backlog,,high,,Launch-readiness,,,"release:r1, epic:launch, type:privacy",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
,Inspannings Monitor,Go-live checklist opstellen,"Maak een checklist voor launch, rollback, monitoring en incidentrespons.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Bron backlog-ID: `ST-805`
|
||||||
|
- Epic / project: `Launch-readiness`
|
||||||
|
- Fase: `Release 1`
|
||||||
|
- Afhankelijk van: `EPIC-08`
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
Team weet hoe launch en eerste incidentrespons verloopt.",Backlog,,high,,Launch-readiness,,,"release:r1, epic:launch, type:ops",,,,,2026-04-17T00:00:00Z,,,,,,,,,Release 1 MVP,,,
|
||||||
|
10
docs/backlog/inspannings-monitor-linear-projects.csv
Normal file
10
docs/backlog/inspannings-monitor-linear-projects.csv
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
Name,Summary,Status,Milestones,Creator,Lead,Members,Created At,Started At,Target Date,Completed At,Canceled At,Teams,Initiatives
|
||||||
|
Fundament,"Leg de technische basis voor release 1 met projectsetup, omgevingen, UI-basis en foutafhandeling.",Planned,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Auth en profiel,"Implementeer accounttoegang, profieldata, onboarding en basisinstellingen per gebruiker.",Planned,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Ochtendcheck-in,"Bouw de ochtendcheck-in met energiescore, slaapkwaliteit en automatische budgetafleiding.",Planned,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Dagplanning,"Maak plannen van activiteiten mogelijk met budgetfeedback, energie-impact en prioriteit.",Planned,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Evaluatie en dagoverzicht,Maak evaluatie van activiteiten en een dagelijks overzicht van gepland versus uitgevoerd mogelijk.,Planned,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Weekoverzicht en inzichten,"Voeg weekterugblik, eenvoudige aggregaties en veilige patroonweergave toe zonder medische claims.",Backlog,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Reflectie en reminders,Voeg optionele T+1/T+2 reflectieprompts en lichte reminderlogica toe voor zwaardere dagen.,Backlog,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Security en operations,"Borg logging, rate limiting, secrets, back-up en owner-only toegangscontrole voor echte gebruikersintroductie.",Planned,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
Launch-readiness,"Rond QA, copy review, accessibility checks, DPIA-input en go-live checks voor release 1 af.",Backlog,,,,,2026-04-17T00:00:00Z,,,,,Inspannings Monitor,Release 1 MVP
|
||||||
|
121
docs/backlog/inspannings-monitor-linear-projects.md
Normal file
121
docs/backlog/inspannings-monitor-linear-projects.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Inspannings Monitor Linear Projects
|
||||||
|
|
||||||
|
Dit document geeft per `Linear Project` een aanbevolen naam, samenvatting, status en praktisch gebruik.
|
||||||
|
|
||||||
|
## Initiative
|
||||||
|
|
||||||
|
### Release 1 MVP
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Wellness-first MVP voor individuele gebruikers, met een lichte plan-doe-evalueer flow voor energiemanagement.`
|
||||||
|
- Doel: alle release-1 projecten samenbrengen onder één duidelijk productdoel
|
||||||
|
|
||||||
|
Volgens de actuele `Linear`-documentatie zijn initiatives workspace-breed, bedoeld om projecten te groeperen rond een organisatorisch doel, en hebben ze een lifecycle met `Planned`, `Active` en `Completed`. Voor jullie huidige fase is `Planned` de juiste startstatus.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
### 1. Fundament
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Leg de technische basis voor release 1 met projectsetup, omgevingen, UI-basis en foutafhandeling.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- duidelijke uitkomst
|
||||||
|
- vroeg in de planning
|
||||||
|
- direct blokkerend voor alle andere projecten
|
||||||
|
|
||||||
|
### 2. Auth en profiel
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Implementeer accounttoegang, profieldata, onboarding en basisinstellingen per gebruiker.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- eigen domein met duidelijke oplevering
|
||||||
|
- nodig voor alle persoonlijke flows
|
||||||
|
|
||||||
|
### 3. Ochtendcheck-in
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Bouw de ochtendcheck-in met energiescore, slaapkwaliteit en automatische budgetafleiding.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- centrale start van de kerngebruikersreis
|
||||||
|
- duidelijke functionele grens
|
||||||
|
|
||||||
|
### 4. Dagplanning
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Maak plannen van activiteiten mogelijk met budgetfeedback, energie-impact en prioriteit.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- aparte UX- en datamodelscope
|
||||||
|
- kern van de planfase
|
||||||
|
|
||||||
|
### 5. Evaluatie en dagoverzicht
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Maak evaluatie van activiteiten en een dagelijks overzicht van gepland versus uitgevoerd mogelijk.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- sluit de kernloop functioneel af
|
||||||
|
- levert directe gebruikerswaarde op
|
||||||
|
|
||||||
|
### 6. Weekoverzicht en inzichten
|
||||||
|
|
||||||
|
- Aanbevolen status: `Backlog`
|
||||||
|
- Samenvatting: `Voeg weekterugblik, eenvoudige aggregaties en veilige patroonweergave toe zonder medische claims.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- logisch vervolg op de basisflow
|
||||||
|
- minder blokkerend dan de eerste vijf projecten
|
||||||
|
|
||||||
|
### 7. Reflectie en reminders
|
||||||
|
|
||||||
|
- Aanbevolen status: `Backlog`
|
||||||
|
- Samenvatting: `Voeg optionele T+1/T+2 reflectieprompts en lichte reminderlogica toe voor zwaardere dagen.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- waardevol, maar niet nodig om de eerste basisflow werkend te krijgen
|
||||||
|
- goed af te bakenen als apart project
|
||||||
|
|
||||||
|
### 8. Security en operations
|
||||||
|
|
||||||
|
- Aanbevolen status: `Planned`
|
||||||
|
- Samenvatting: `Borg logging, rate limiting, secrets, back-up en owner-only toegangscontrole voor echte gebruikersintroductie.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- releasekritisch
|
||||||
|
- loopt parallel aan featurebouw
|
||||||
|
|
||||||
|
### 9. Launch-readiness
|
||||||
|
|
||||||
|
- Aanbevolen status: `Backlog`
|
||||||
|
- Samenvatting: `Rond QA, copy review, accessibility checks, DPIA-input en go-live checks voor release 1 af.`
|
||||||
|
- Waarom dit een project is:
|
||||||
|
- hoort als apart releaseproject zichtbaar te zijn
|
||||||
|
- wordt pas later actief, maar moet wel vroeg bestaan
|
||||||
|
|
||||||
|
## Aanbevolen praktische werkwijze in Linear
|
||||||
|
|
||||||
|
- Gebruik `Projects` voor deze 9 grotere werkstromen.
|
||||||
|
- Gebruik `Issues` voor de individuele stories.
|
||||||
|
- Gebruik voorlopig geen milestones.
|
||||||
|
- Gebruik voorlopig geen cycles.
|
||||||
|
- Zet een project pas op `In Progress` zodra er daadwerkelijk actief werk in loopt.
|
||||||
|
- Laat `Weekoverzicht en inzichten`, `Reflectie en reminders` en `Launch-readiness` aanvankelijk op `Backlog` staan.
|
||||||
|
|
||||||
|
## Aanbevolen eerste statusverdeling
|
||||||
|
|
||||||
|
### Start op `Planned`
|
||||||
|
|
||||||
|
- `Fundament`
|
||||||
|
- `Auth en profiel`
|
||||||
|
- `Ochtendcheck-in`
|
||||||
|
- `Dagplanning`
|
||||||
|
- `Evaluatie en dagoverzicht`
|
||||||
|
- `Security en operations`
|
||||||
|
|
||||||
|
### Start op `Backlog`
|
||||||
|
|
||||||
|
- `Weekoverzicht en inzichten`
|
||||||
|
- `Reflectie en reminders`
|
||||||
|
- `Launch-readiness`
|
||||||
|
|
||||||
|
## Bronnen
|
||||||
|
|
||||||
|
- [Linear Project status](https://linear.app/docs/project-status)
|
||||||
|
- [Linear Projects](https://linear.app/docs/projects)
|
||||||
|
- [Linear Project overview](https://linear.app/docs/project-overview)
|
||||||
|
- [Linear Initiatives](https://linear.app/docs/initiatives)
|
||||||
112
docs/backlog/inspannings-monitor-linear-setup.md
Normal file
112
docs/backlog/inspannings-monitor-linear-setup.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Inspannings Monitor in Linear
|
||||||
|
|
||||||
|
Dit document vertaalt de huidige backlog en documentatieset naar een praktische `Linear`-inrichting.
|
||||||
|
|
||||||
|
## Waarom deze inrichting
|
||||||
|
|
||||||
|
Volgens de actuele `Linear`-documentatie is een workspace het hoogste niveau en beveelt Linear in de praktijk aan om per bedrijf één workspace te gebruiken. Ook is een issue in Linear altijd gekoppeld aan precies één team, terwijl projecten grotere eenheden van werk zijn met een duidelijke uitkomst en geplande afronding.
|
||||||
|
|
||||||
|
Voor `Inspannings Monitor` betekent dat: houd het in het begin eenvoudig en maak één team voor release 1.
|
||||||
|
|
||||||
|
## Aanbevolen structuur
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
- `Inspannings Monitor`
|
||||||
|
|
||||||
|
### Team
|
||||||
|
|
||||||
|
- `Inspannings Monitor`
|
||||||
|
|
||||||
|
Gebruik één top-level team voor release 1. Dat past goed bij het feit dat release 1 alleen voor individuele gebruikers is en dat de backlog nog niet over meerdere product- of engineeringteams verdeeld hoeft te worden.
|
||||||
|
|
||||||
|
### Initiative
|
||||||
|
|
||||||
|
- `Release 1 MVP`
|
||||||
|
|
||||||
|
Gebruik één initiative als overkoepelend kader voor de eerste release.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Maak de huidige epics als `Projects` aan in Linear:
|
||||||
|
|
||||||
|
1. `Fundament`
|
||||||
|
2. `Auth en profiel`
|
||||||
|
3. `Ochtendcheck-in`
|
||||||
|
4. `Dagplanning`
|
||||||
|
5. `Evaluatie en dagoverzicht`
|
||||||
|
6. `Weekoverzicht en inzichten`
|
||||||
|
7. `Reflectie en reminders`
|
||||||
|
8. `Security en operations`
|
||||||
|
9. `Launch-readiness`
|
||||||
|
|
||||||
|
## Aanbevolen labels
|
||||||
|
|
||||||
|
Houd labels klein en functioneel:
|
||||||
|
|
||||||
|
- `release:r1`
|
||||||
|
- `type:build`
|
||||||
|
- `type:ui`
|
||||||
|
- `type:logic`
|
||||||
|
- `type:qa`
|
||||||
|
- `type:security`
|
||||||
|
- `type:ops`
|
||||||
|
- `type:ux`
|
||||||
|
- `type:content`
|
||||||
|
- `type:privacy`
|
||||||
|
- `epic:fundament`
|
||||||
|
- `epic:auth-profiel`
|
||||||
|
- `epic:ochtendcheckin`
|
||||||
|
- `epic:dagplanning`
|
||||||
|
- `epic:evaluatie`
|
||||||
|
- `epic:weekoverzicht`
|
||||||
|
- `epic:reflectie`
|
||||||
|
- `epic:security-ops`
|
||||||
|
- `epic:launch`
|
||||||
|
|
||||||
|
## Aanbevolen statusgebruik
|
||||||
|
|
||||||
|
Voor de eerste release zou ik de workflow simpel houden:
|
||||||
|
|
||||||
|
- `Backlog`
|
||||||
|
- `Todo`
|
||||||
|
- `In Progress`
|
||||||
|
- `Done`
|
||||||
|
- `Canceled`
|
||||||
|
|
||||||
|
Begin zonder extra workflowstappen zoals `In Review`, tenzij jullie daar direct echt behoefte aan hebben. Linear is sterk juist wanneer je niet te vroeg te veel proceslagen toevoegt.
|
||||||
|
|
||||||
|
## Hoe ik de backlog heb gemapt
|
||||||
|
|
||||||
|
- `Epics` uit onze backlog zijn gemapt naar `Projects` in Linear.
|
||||||
|
- `Stories` zijn gemapt naar `Issues`.
|
||||||
|
- `P0` is gemapt naar `high`.
|
||||||
|
- `P1` is gemapt naar `medium`.
|
||||||
|
- Alle issues starten in `Backlog`.
|
||||||
|
- Het labelpakket uit de bestaande backlog blijft behouden.
|
||||||
|
|
||||||
|
## Aanbevolen importaanpak
|
||||||
|
|
||||||
|
1. Maak in Linear eerst de workspace en het team aan.
|
||||||
|
2. Maak daarna handmatig de `Initiative` en de negen `Projects` aan.
|
||||||
|
3. Gebruik het gegenereerde bestand [inspannings-monitor-linear-import-issues.csv](./inspannings-monitor-linear-import-issues.csv).
|
||||||
|
4. Gebruik de importroute die Linear documenteert voor `Other`-bronnen / `Linear CSV` via hun importer/CLI.
|
||||||
|
5. Controleer na import of `Project` en `Initiatives` goed zijn overgekomen.
|
||||||
|
6. Als die velden niet automatisch gekoppeld blijken, kun je in Linear issues per `epic:*` label filteren en daarna in bulk aan het juiste project koppelen.
|
||||||
|
|
||||||
|
## Belangrijke noot over de CSV
|
||||||
|
|
||||||
|
De gegenereerde CSV volgt de actuele exportkoppen van Linear, zodat het formaat dicht op het eigen model van Linear ligt. De importdocumentatie noemt expliciet onder meer `Title`, `Description`, `Priority`, `Status`, `Assignee`, `Created`, `Completed`, `Labels` en `Estimate` als relevante velden voor een `Other`-import. Ik heb daarnaast ook `Team`, `Project` en `Initiatives` ingevuld op basis van Linear’s eigen exportstructuur. Daardoor is de CSV zo bruikbaar mogelijk, maar het blijft verstandig om na import even te verifiëren dat projectkoppelingen exact zijn overgekomen.
|
||||||
|
|
||||||
|
## Cycles
|
||||||
|
|
||||||
|
Ik zou `Cycles` nog niet meteen aanzetten. Eerst de basisflow goed krijgen, daarna pas time-boxing toevoegen. Linear ondersteunt cycles per team, maar voor deze eerste release levert een eenvoudige project- en issue-structuur waarschijnlijk meer rust op dan direct sprintdiscipline.
|
||||||
|
|
||||||
|
## Bestanden in deze map
|
||||||
|
|
||||||
|
- [inspannings-monitor-backlog.md](./inspannings-monitor-backlog.md)
|
||||||
|
- [inspannings-monitor-backlog.csv](./inspannings-monitor-backlog.csv)
|
||||||
|
- [inspannings-monitor-linear-import-issues.csv](./inspannings-monitor-linear-import-issues.csv)
|
||||||
|
- [inspannings-monitor-linear-projects.csv](./inspannings-monitor-linear-projects.csv)
|
||||||
|
- [inspannings-monitor-linear-projects.md](./inspannings-monitor-linear-projects.md)
|
||||||
|
- [generate_linear_backlog_assets.py](./generate_linear_backlog_assets.py)
|
||||||
BIN
docs/energypace-01-productkader-en-positionering-v05.docx
Normal file
BIN
docs/energypace-01-productkader-en-positionering-v05.docx
Normal file
Binary file not shown.
BIN
docs/energypace-02-functionele-specificatie-mvp-v05.docx
Normal file
BIN
docs/energypace-02-functionele-specificatie-mvp-v05.docx
Normal file
Binary file not shown.
BIN
docs/energypace-03-privacy-security-safety-baseline-v01.docx
Normal file
BIN
docs/energypace-03-privacy-security-safety-baseline-v01.docx
Normal file
Binary file not shown.
BIN
docs/energypace-04-roadmap-wellness-naar-medisch-v01.docx
Normal file
BIN
docs/energypace-04-roadmap-wellness-naar-medisch-v01.docx
Normal file
Binary file not shown.
1207
docs/generate_inspannings_monitor_docs.py
Normal file
1207
docs/generate_inspannings_monitor_docs.py
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
energypace-specificatie-verbeteradvies-2026-04-17.docx
Normal file
BIN
energypace-specificatie-verbeteradvies-2026-04-17.docx
Normal file
Binary file not shown.
5
eslint.config.mjs
Normal file
5
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
const eslintConfig = [...nextVitals];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
71
lib/auth/messages.ts
Normal file
71
lib/auth/messages.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
export type AuthNoticeTone = "error" | "success" | "info";
|
||||||
|
|
||||||
|
export type AuthNotice = {
|
||||||
|
tone: AuthNoticeTone;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessages: Record<string, AuthNotice> = {
|
||||||
|
"auth-not-configured": {
|
||||||
|
tone: "error",
|
||||||
|
text: "Supabase is nog niet geconfigureerd. Voeg eerst je URL en publishable key toe.",
|
||||||
|
},
|
||||||
|
"invalid-credentials": {
|
||||||
|
tone: "error",
|
||||||
|
text: "De combinatie van e-mailadres en wachtwoord klopt niet.",
|
||||||
|
},
|
||||||
|
"email-not-confirmed": {
|
||||||
|
tone: "error",
|
||||||
|
text: "Bevestig eerst je e-mailadres via de link in je inbox.",
|
||||||
|
},
|
||||||
|
"missing-fields": {
|
||||||
|
tone: "error",
|
||||||
|
text: "Vul zowel je e-mailadres als je wachtwoord in.",
|
||||||
|
},
|
||||||
|
"signup-failed": {
|
||||||
|
tone: "error",
|
||||||
|
text: "Je account kon niet worden aangemaakt. Probeer het opnieuw.",
|
||||||
|
},
|
||||||
|
"signup-rate-limited": {
|
||||||
|
tone: "error",
|
||||||
|
text: "Er zijn nu te veel verificatie-e-mails verstuurd. Wacht even en probeer het daarna opnieuw.",
|
||||||
|
},
|
||||||
|
"login-failed": {
|
||||||
|
tone: "error",
|
||||||
|
text: "Inloggen is niet gelukt. Probeer het opnieuw.",
|
||||||
|
},
|
||||||
|
"verification-failed": {
|
||||||
|
tone: "error",
|
||||||
|
text: "De verificatielink is ongeldig of verlopen. Vraag zo nodig een nieuwe aan.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMessages: Record<string, AuthNotice> = {
|
||||||
|
"check-email": {
|
||||||
|
tone: "success",
|
||||||
|
text: "Controleer je e-mail en activeer je account via de verificatielink.",
|
||||||
|
},
|
||||||
|
"signed-out": {
|
||||||
|
tone: "info",
|
||||||
|
text: "Je bent uitgelogd.",
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
tone: "success",
|
||||||
|
text: "Je e-mailadres is bevestigd. Welkom terug.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAuthNotice(
|
||||||
|
error?: string | null,
|
||||||
|
status?: string | null,
|
||||||
|
): AuthNotice | null {
|
||||||
|
if (error && errorMessages[error]) {
|
||||||
|
return errorMessages[error];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && statusMessages[status]) {
|
||||||
|
return statusMessages[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
41
lib/auth/navigation.ts
Normal file
41
lib/auth/navigation.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const DEFAULT_NEXT_PATH = "/dashboard";
|
||||||
|
|
||||||
|
export function sanitizeNextPath(candidate?: string | null): string {
|
||||||
|
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//")) {
|
||||||
|
return DEFAULT_NEXT_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPathWithQuery(
|
||||||
|
pathname: string,
|
||||||
|
params: Record<string, string | null | undefined>,
|
||||||
|
): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value) {
|
||||||
|
search.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = search.toString();
|
||||||
|
return query ? `${pathname}?${query}` : pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestOrigin(headers: Pick<Headers, "get">): string {
|
||||||
|
const origin = headers.get("origin");
|
||||||
|
if (origin) {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = headers.get("x-forwarded-proto") ?? "http";
|
||||||
|
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http://localhost:3000";
|
||||||
|
}
|
||||||
64
lib/auth/session.ts
Normal file
64
lib/auth/session.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { hasSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
|
type ClaimsShape = {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthenticatedUser = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
isConfigured: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
userId: string | null;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAuthenticatedUser(): Promise<AuthenticatedUser | null> {
|
||||||
|
if (!hasSupabaseEnv()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase.auth.getClaims();
|
||||||
|
|
||||||
|
if (!data?.claims) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = data.claims as ClaimsShape;
|
||||||
|
const userId = typeof claims.sub === "string" ? claims.sub : null;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
email: typeof claims.email === "string" ? claims.email : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthState(): Promise<AuthState> {
|
||||||
|
if (!hasSupabaseEnv()) {
|
||||||
|
return {
|
||||||
|
isConfigured: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
userId: null,
|
||||||
|
email: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedUser = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConfigured: true,
|
||||||
|
isAuthenticated: Boolean(authenticatedUser),
|
||||||
|
userId: authenticatedUser?.id ?? null,
|
||||||
|
email: authenticatedUser?.email ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
lib/onboarding/options.ts
Normal file
28
lib/onboarding/options.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export const ONBOARDING_TIMEZONE_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "Europe/Amsterdam",
|
||||||
|
label: "Europa/Amsterdam",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Europe/Brussels",
|
||||||
|
label: "Europa/Brussel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Europe/Berlin",
|
||||||
|
label: "Europa/Berlijn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "UTC",
|
||||||
|
label: "UTC",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ONBOARDING_TIMEZONE_SET = new Set(
|
||||||
|
ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isSupportedOnboardingTimezone(value: string) {
|
||||||
|
return ONBOARDING_TIMEZONE_SET.has(
|
||||||
|
value as (typeof ONBOARDING_TIMEZONE_OPTIONS)[number]["value"],
|
||||||
|
);
|
||||||
|
}
|
||||||
385
lib/profile/service.ts
Normal file
385
lib/profile/service.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth/session";
|
||||||
|
import { isSupportedOnboardingTimezone } from "@/lib/onboarding/options";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import type {
|
||||||
|
OnboardingSubmission,
|
||||||
|
ProfileBundle,
|
||||||
|
ProfileRecord,
|
||||||
|
SettingsSubmission,
|
||||||
|
UserSettingsRecord,
|
||||||
|
} from "@/lib/profile/types";
|
||||||
|
|
||||||
|
type SupabaseServerClient = Awaited<ReturnType<typeof createClient>>;
|
||||||
|
|
||||||
|
type ProfileRow = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
onboarding_seen: boolean;
|
||||||
|
onboarding_completed: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserSettingsRow = {
|
||||||
|
profile_id: string;
|
||||||
|
morning_reminder_enabled: boolean;
|
||||||
|
morning_reminder_time: string | null;
|
||||||
|
reflection_reminder_enabled: boolean;
|
||||||
|
show_energy_points: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfileInsert = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
onboarding_seen: boolean;
|
||||||
|
onboarding_completed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserSettingsInsert = {
|
||||||
|
profile_id: string;
|
||||||
|
morning_reminder_enabled: boolean;
|
||||||
|
morning_reminder_time: string | null;
|
||||||
|
reflection_reminder_enabled: boolean;
|
||||||
|
show_energy_points: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_COLUMNS =
|
||||||
|
"id, email, display_name, locale, timezone, onboarding_seen, onboarding_completed, created_at, updated_at";
|
||||||
|
const USER_SETTINGS_COLUMNS =
|
||||||
|
"profile_id, morning_reminder_enabled, morning_reminder_time, reflection_reminder_enabled, show_energy_points, created_at, updated_at";
|
||||||
|
|
||||||
|
const DEFAULT_LOCALE = "nl-NL";
|
||||||
|
const DEFAULT_TIMEZONE = "Europe/Amsterdam";
|
||||||
|
const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]);
|
||||||
|
|
||||||
|
function mapProfileRow(row: ProfileRow): ProfileRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
displayName: row.display_name,
|
||||||
|
locale: row.locale,
|
||||||
|
timezone: row.timezone,
|
||||||
|
onboardingSeen: row.onboarding_seen,
|
||||||
|
onboardingCompleted: row.onboarding_completed,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserSettingsRow(row: UserSettingsRow): UserSettingsRecord {
|
||||||
|
return {
|
||||||
|
profileId: row.profile_id,
|
||||||
|
morningReminderEnabled: row.morning_reminder_enabled,
|
||||||
|
morningReminderTime: row.morning_reminder_time,
|
||||||
|
reflectionReminderEnabled: row.reflection_reminder_enabled,
|
||||||
|
showEnergyPoints: row.show_energy_points,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultProfileFromClaims(user: {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
}): ProfileInsert {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
display_name: null,
|
||||||
|
locale: DEFAULT_LOCALE,
|
||||||
|
timezone: DEFAULT_TIMEZONE,
|
||||||
|
onboarding_seen: false,
|
||||||
|
onboarding_completed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultSettings(profileId: string): UserSettingsInsert {
|
||||||
|
return {
|
||||||
|
profile_id: profileId,
|
||||||
|
morning_reminder_enabled: false,
|
||||||
|
morning_reminder_time: null,
|
||||||
|
reflection_reminder_enabled: false,
|
||||||
|
show_energy_points: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: string | null) {
|
||||||
|
const trimmedValue = value?.trim() ?? "";
|
||||||
|
return trimmedValue ? trimmedValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReminderTime(value: string | null, enabled: boolean) {
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedValue = value?.trim() ?? "";
|
||||||
|
return trimmedValue || "08:30";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocale(value: string) {
|
||||||
|
return SUPPORTED_LOCALES.has(value) ? value : DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimezone(value: string) {
|
||||||
|
return isSupportedOnboardingTimezone(value) ? value : DEFAULT_TIMEZONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProfileRow(
|
||||||
|
supabase: SupabaseServerClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ProfileRow | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select(PROFILE_COLUMNS)
|
||||||
|
.eq("id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Profiel kon niet worden geladen: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUserSettingsRow(
|
||||||
|
supabase: SupabaseServerClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<UserSettingsRow | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("user_settings")
|
||||||
|
.select(USER_SETTINGS_COLUMNS)
|
||||||
|
.eq("profile_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Instellingen konden niet worden geladen: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertMissingProfile(
|
||||||
|
supabase: SupabaseServerClient,
|
||||||
|
user: { id: string; email: string | null },
|
||||||
|
) {
|
||||||
|
const { error } = await supabase.from("profiles").upsert(
|
||||||
|
buildDefaultProfileFromClaims(user),
|
||||||
|
{
|
||||||
|
onConflict: "id",
|
||||||
|
ignoreDuplicates: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Profiel kon niet worden aangemaakt: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProfileEmailIfNeeded(
|
||||||
|
supabase: SupabaseServerClient,
|
||||||
|
profile: ProfileRow,
|
||||||
|
email: string | null,
|
||||||
|
): Promise<ProfileRow> {
|
||||||
|
if (!email || profile.email === email) {
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ email })
|
||||||
|
.eq("id", profile.id)
|
||||||
|
.select(PROFILE_COLUMNS)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Profiel-e-mailadres kon niet worden bijgewerkt: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertMissingUserSettings(
|
||||||
|
supabase: SupabaseServerClient,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const { error } = await supabase.from("user_settings").upsert(
|
||||||
|
buildDefaultSettings(userId),
|
||||||
|
{
|
||||||
|
onConflict: "profile_id",
|
||||||
|
ignoreDuplicates: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Instellingen konden niet worden aangemaakt: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOnboardingSeenForCurrentUser() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ onboarding_seen: true, onboarding_completed: false })
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Onboardingstatus kon niet worden bijgewerkt: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeOnboardingForCurrentUser(
|
||||||
|
submission: OnboardingSubmission,
|
||||||
|
) {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = resolveTimezone(submission.timezone);
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const displayName = normalizeDisplayName(submission.displayName);
|
||||||
|
const morningReminderTime = normalizeReminderTime(
|
||||||
|
submission.morningReminderTime,
|
||||||
|
submission.morningReminderEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { error: profileError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
display_name: displayName,
|
||||||
|
timezone,
|
||||||
|
onboarding_seen: true,
|
||||||
|
onboarding_completed: true,
|
||||||
|
})
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
throw new Error(`Profiel kon niet worden bijgewerkt: ${profileError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: settingsError } = await supabase
|
||||||
|
.from("user_settings")
|
||||||
|
.update({
|
||||||
|
morning_reminder_enabled: submission.morningReminderEnabled,
|
||||||
|
morning_reminder_time: morningReminderTime,
|
||||||
|
reflection_reminder_enabled: submission.reflectionReminderEnabled,
|
||||||
|
show_energy_points: submission.showEnergyPoints,
|
||||||
|
})
|
||||||
|
.eq("profile_id", user.id);
|
||||||
|
|
||||||
|
if (settingsError) {
|
||||||
|
throw new Error(`Instellingen konden niet worden bijgewerkt: ${settingsError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureProfileBundleForCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettingsForCurrentUser(
|
||||||
|
submission: SettingsSubmission,
|
||||||
|
) {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const locale = normalizeLocale(submission.locale);
|
||||||
|
const timezone = resolveTimezone(submission.timezone);
|
||||||
|
const morningReminderTime = normalizeReminderTime(
|
||||||
|
submission.morningReminderTime,
|
||||||
|
submission.morningReminderEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { error: profileError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
locale,
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
throw new Error(`Profielinstellingen konden niet worden bijgewerkt: ${profileError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: settingsError } = await supabase
|
||||||
|
.from("user_settings")
|
||||||
|
.update({
|
||||||
|
morning_reminder_enabled: submission.morningReminderEnabled,
|
||||||
|
morning_reminder_time: morningReminderTime,
|
||||||
|
reflection_reminder_enabled: submission.reflectionReminderEnabled,
|
||||||
|
show_energy_points: submission.showEnergyPoints,
|
||||||
|
})
|
||||||
|
.eq("profile_id", user.id);
|
||||||
|
|
||||||
|
if (settingsError) {
|
||||||
|
throw new Error(`Gebruikersinstellingen konden niet worden bijgewerkt: ${settingsError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureProfileBundleForCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// We bootstrap records app-side so the first protected page load is enough
|
||||||
|
// to give every authenticated user a minimal profile and settings basis.
|
||||||
|
let profileRow = await readProfileRow(supabase, user.id);
|
||||||
|
|
||||||
|
if (!profileRow) {
|
||||||
|
await insertMissingProfile(supabase, user);
|
||||||
|
profileRow = await readProfileRow(supabase, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileRow) {
|
||||||
|
throw new Error("Profielrecord ontbreekt na bootstrap.");
|
||||||
|
}
|
||||||
|
|
||||||
|
profileRow = await syncProfileEmailIfNeeded(supabase, profileRow, user.email);
|
||||||
|
|
||||||
|
let userSettingsRow = await readUserSettingsRow(supabase, user.id);
|
||||||
|
|
||||||
|
if (!userSettingsRow) {
|
||||||
|
await insertMissingUserSettings(supabase, user.id);
|
||||||
|
userSettingsRow = await readUserSettingsRow(supabase, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userSettingsRow) {
|
||||||
|
throw new Error("Settingsrecord ontbreekt na bootstrap.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: mapProfileRow(profileRow),
|
||||||
|
settings: mapUserSettingsRow(userSettingsRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
|
||||||
|
return ensureProfileBundleForCurrentUser();
|
||||||
|
}
|
||||||
44
lib/profile/types.ts
Normal file
44
lib/profile/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export type ProfileRecord = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
onboardingSeen: boolean;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSettingsRecord = {
|
||||||
|
profileId: string;
|
||||||
|
morningReminderEnabled: boolean;
|
||||||
|
morningReminderTime: string | null;
|
||||||
|
reflectionReminderEnabled: boolean;
|
||||||
|
showEnergyPoints: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileBundle = {
|
||||||
|
profile: ProfileRecord;
|
||||||
|
settings: UserSettingsRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnboardingSubmission = {
|
||||||
|
displayName: string | null;
|
||||||
|
timezone: string;
|
||||||
|
morningReminderEnabled: boolean;
|
||||||
|
morningReminderTime: string | null;
|
||||||
|
reflectionReminderEnabled: boolean;
|
||||||
|
showEnergyPoints: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingsSubmission = {
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
morningReminderEnabled: boolean;
|
||||||
|
morningReminderTime: string | null;
|
||||||
|
reflectionReminderEnabled: boolean;
|
||||||
|
showEnergyPoints: boolean;
|
||||||
|
};
|
||||||
15
lib/supabase/client.ts
Normal file
15
lib/supabase/client.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import { getSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
|
let browserClient: ReturnType<typeof createBrowserClient> | undefined;
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
if (!browserClient) {
|
||||||
|
const { url, publishableKey } = getSupabaseEnv();
|
||||||
|
browserClient = createBrowserClient(url, publishableKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return browserClient;
|
||||||
|
}
|
||||||
22
lib/supabase/config.ts
Normal file
22
lib/supabase/config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export function hasSupabaseEnv(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupabaseEnv() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
if (!url || !publishableKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Supabase configuratie ontbreekt. Voeg NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY toe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
publishableKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
lib/supabase/proxy.ts
Normal file
40
lib/supabase/proxy.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { getSupabaseEnv, hasSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
|
export async function updateSession(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasSupabaseEnv()) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, publishableKey } = getSupabaseEnv();
|
||||||
|
|
||||||
|
const supabase = createServerClient(url, publishableKey, {
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) => {
|
||||||
|
request.cookies.set(name, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
response = NextResponse.next({
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => {
|
||||||
|
response.cookies.set(name, value, options);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.auth.getClaims();
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
26
lib/supabase/server.ts
Normal file
26
lib/supabase/server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const { url, publishableKey } = getSupabaseEnv();
|
||||||
|
|
||||||
|
return createServerClient(url, publishableKey, {
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Server Components cannot always write cookies directly.
|
||||||
|
// The proxy keeps the session in sync for those cases.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
10056
package-lock.json
generated
Normal file
10056
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "inspannings-monitor",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.0",
|
||||||
|
"@supabase/ssr": "^0.10.2",
|
||||||
|
"@supabase/supabase-js": "^2.103.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"next": "16.2.0",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"shadcn": "^4.3.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "latest",
|
||||||
|
"@types/node": "latest",
|
||||||
|
"@types/react": "latest",
|
||||||
|
"@types/react-dom": "latest",
|
||||||
|
"eslint": "latest",
|
||||||
|
"eslint-config-next": "16.2.0",
|
||||||
|
"postcss": "latest",
|
||||||
|
"tailwindcss": "latest",
|
||||||
|
"typescript": "latest"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
12
proxy.ts
Normal file
12
proxy.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { updateSession } from "@/lib/supabase/proxy";
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
return updateSession(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.90.0
|
||||||
1
supabase/.temp/gotrue-version
Normal file
1
supabase/.temp/gotrue-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.188.1
|
||||||
1
supabase/.temp/linked-project.json
Normal file
1
supabase/.temp/linked-project.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"ref":"yntzfgnkrwjlnbaxxkkc","name":"madhura68's Project","organization_id":"muclgkbblfugxcxrrjaj","organization_slug":"muclgkbblfugxcxrrjaj"}
|
||||||
1
supabase/.temp/pooler-url
Normal file
1
supabase/.temp/pooler-url
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
postgresql://postgres.yntzfgnkrwjlnbaxxkkc@aws-0-eu-west-1.pooler.supabase.com:5432/postgres
|
||||||
1
supabase/.temp/postgres-version
Normal file
1
supabase/.temp/postgres-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
17.6.1.104
|
||||||
1
supabase/.temp/project-ref
Normal file
1
supabase/.temp/project-ref
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
yntzfgnkrwjlnbaxxkkc
|
||||||
1
supabase/.temp/rest-version
Normal file
1
supabase/.temp/rest-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v14.5
|
||||||
1
supabase/.temp/storage-migration
Normal file
1
supabase/.temp/storage-migration
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
operation-ergonomics
|
||||||
1
supabase/.temp/storage-version
Normal file
1
supabase/.temp/storage-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v1.48.20
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
alter table public.profiles
|
||||||
|
add column if not exists onboarding_seen boolean not null default false;
|
||||||
|
|
||||||
|
update public.profiles
|
||||||
|
set onboarding_seen = true
|
||||||
|
where onboarding_completed = true
|
||||||
|
and onboarding_seen = false;
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = timezone('utc', now());
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users (id) on delete cascade,
|
||||||
|
email text,
|
||||||
|
display_name text,
|
||||||
|
locale text not null default 'nl-NL',
|
||||||
|
timezone text not null default 'Europe/Amsterdam',
|
||||||
|
onboarding_completed boolean not null default false,
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.user_settings (
|
||||||
|
profile_id uuid primary key references public.profiles (id) on delete cascade,
|
||||||
|
morning_reminder_enabled boolean not null default false,
|
||||||
|
morning_reminder_time time,
|
||||||
|
reflection_reminder_enabled boolean not null default false,
|
||||||
|
show_energy_points boolean not null default true,
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
grant usage on schema public to authenticated;
|
||||||
|
grant select, insert, update on table public.profiles to authenticated;
|
||||||
|
grant select, insert, update on table public.user_settings to authenticated;
|
||||||
|
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
alter table public.user_settings enable row level security;
|
||||||
|
|
||||||
|
drop trigger if exists set_profiles_updated_at on public.profiles;
|
||||||
|
create trigger set_profiles_updated_at
|
||||||
|
before update on public.profiles
|
||||||
|
for each row
|
||||||
|
execute function public.set_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists set_user_settings_updated_at on public.user_settings;
|
||||||
|
create trigger set_user_settings_updated_at
|
||||||
|
before update on public.user_settings
|
||||||
|
for each row
|
||||||
|
execute function public.set_updated_at();
|
||||||
|
|
||||||
|
drop policy if exists "profiles_select_own" on public.profiles;
|
||||||
|
create policy "profiles_select_own"
|
||||||
|
on public.profiles
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using ((select auth.uid()) = id);
|
||||||
|
|
||||||
|
drop policy if exists "profiles_insert_own" on public.profiles;
|
||||||
|
create policy "profiles_insert_own"
|
||||||
|
on public.profiles
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check ((select auth.uid()) = id);
|
||||||
|
|
||||||
|
drop policy if exists "profiles_update_own" on public.profiles;
|
||||||
|
create policy "profiles_update_own"
|
||||||
|
on public.profiles
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using ((select auth.uid()) = id)
|
||||||
|
with check ((select auth.uid()) = id);
|
||||||
|
|
||||||
|
drop policy if exists "user_settings_select_own" on public.user_settings;
|
||||||
|
create policy "user_settings_select_own"
|
||||||
|
on public.user_settings
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using ((select auth.uid()) = profile_id);
|
||||||
|
|
||||||
|
drop policy if exists "user_settings_insert_own" on public.user_settings;
|
||||||
|
create policy "user_settings_insert_own"
|
||||||
|
on public.user_settings
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check ((select auth.uid()) = profile_id);
|
||||||
|
|
||||||
|
drop policy if exists "user_settings_update_own" on public.user_settings;
|
||||||
|
create policy "user_settings_update_own"
|
||||||
|
on public.user_settings
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using ((select auth.uid()) = profile_id)
|
||||||
|
with check ((select auth.uid()) = profile_id);
|
||||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue