diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 4b39fd9..c1a641c 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -168,12 +168,12 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps {planningStatus?.activities.length ? `${planningStatus.activities.length} activiteiten voor vandaag` - : "Nog niets gepland voor vandaag"} + : "Nog niets toegevoegd voor vandaag"} - Plan kleine, concrete activiteiten voor vandaag en bouw daarna verder op budgetfeedback en evaluatie. + Plan kleine, concrete activiteiten voor vandaag en leg ook onverwachte activiteiten vast als je dag anders loopt dan gedacht.
diff --git a/app/planning/actions.ts b/app/planning/actions.ts index bdff837..c0ff0a6 100644 --- a/app/planning/actions.ts +++ b/app/planning/actions.ts @@ -8,11 +8,13 @@ import { ACTIVITY_STATUS_VALUES, } from "@/lib/planning/options"; import { + createAdHocActivityForTodayForCurrentUser, createActivityForTodayForCurrentUser, updateActivityEvaluationForTodayForCurrentUser, updateActivityStatusForTodayForCurrentUser, } from "@/lib/planning/service"; import type { + CreateAdHocActivitySubmission, CreateActivitySubmission, UpdateActivityEvaluationSubmission, UpdateActivityStatusSubmission, @@ -59,6 +61,33 @@ function buildCreateActivitySubmission(formData: FormData): CreateActivitySubmis }; } +function buildCreateAdHocActivitySubmission( + formData: FormData, +): CreateAdHocActivitySubmission { + const name = assertMaxLength( + getRequiredString(formData, "name", "invalid-ad-hoc-activity-input"), + 120, + "invalid-ad-hoc-activity-input", + ); + + return { + name, + categoryId: getUuidValue(formData, "categoryId", "invalid-ad-hoc-activity-input"), + durationMinutes: getIntegerValue( + formData, + "durationMinutes", + { min: 1, max: 720 }, + "invalid-ad-hoc-activity-input", + ), + impactLevel: getEnumValue( + formData, + "impactLevel", + ACTIVITY_IMPACT_LEVEL_VALUES, + "invalid-ad-hoc-activity-input", + ), + }; +} + function buildUpdateActivityStatusSubmission( formData: FormData, ): UpdateActivityStatusSubmission { @@ -113,6 +142,30 @@ export async function createActivityAction( return null; } +export async function createAdHocActivityAction( + _previousState: null, + formData: FormData, +): Promise { + try { + await createAdHocActivityForTodayForCurrentUser( + buildCreateAdHocActivitySubmission(formData), + ); + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/planning", { error: error.code })); + } + + if (error instanceof Error && error.message === "Ongeldige activiteitcategorie.") { + redirect(buildPathWithQuery("/planning", { error: "invalid-ad-hoc-activity-input" })); + } + + redirect(buildPathWithQuery("/planning", { error: "ad-hoc-activity-failed" })); + } + + redirect(buildPathWithQuery("/planning", { status: "ad-hoc-activity-saved" })); + return null; +} + export async function updateActivityStatusAction( _previousState: null, formData: FormData, diff --git a/app/planning/page.tsx b/app/planning/page.tsx index 7ac474d..794b7f4 100644 --- a/app/planning/page.tsx +++ b/app/planning/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { AdHocActivityForm } from "@/components/planning/ad-hoc-activity-form"; import { AppShell } from "@/components/navigation/app-shell"; import { PageIntro } from "@/components/navigation/page-intro"; import { ActivityForm } from "@/components/planning/activity-form"; @@ -88,11 +89,18 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps) />
- +
+ + +
diff --git a/components/planning/activity-form.tsx b/components/planning/activity-form.tsx index d2da54e..97763f7 100644 --- a/components/planning/activity-form.tsx +++ b/components/planning/activity-form.tsx @@ -197,7 +197,7 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo

{previewPoints === null ? "Kies een geldige duur en impact om te zien hoeveel punten deze activiteit ongeveer toevoegt." - : `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je totaal zou dan uitkomen op ${previewMeter?.plannedPoints ?? currentMeter.plannedPoints} geplande punten.`} + : `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je totaal zou dan uitkomen op ${previewMeter?.totalPoints ?? currentMeter.totalPoints} punten in beeld.`}

{dailyBudget !== null && previewMeter ? (

@@ -324,7 +324,7 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo

{isPending ? "Je activiteit wordt opgeslagen..." - : "Je activiteit wordt vandaag toegevoegd met status `gepland`, waarna de meter direct opnieuw wordt berekend."} + : "Je activiteit wordt vandaag toegevoegd met status `gepland`, waarna je dagtotaal direct opnieuw wordt berekend."}

+ ))} +
+ + + +
+
+ +

+ Kies hoe belastend deze onverwachte activiteit achteraf aanvoelde. +

+
+
+ {ACTIVITY_IMPACT_OPTIONS.map((option) => { + const isSelected = impactLevel === option.value; + + return ( + + ); + })} +
+
+ + + +

Effect op je dagtotaal

+

+ {previewPoints === null + ? "Kies een geldige duur en impact om te zien hoeveel punten deze ongeplande activiteit ongeveer toevoegt." + : `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je dagtotaal zou dan uitkomen op ${previewMeter?.totalPoints ?? currentMeter.totalPoints} punten.`} +

+ {dailyBudget !== null && previewMeter ? ( +

+ Dat is {previewMeter.dailyBudget} punten budget, met daarna nog{" "} + {previewMeter.remainingBudget} punten ruimte. +

+ ) : null} + {previewMeter?.isOverBudget ? ( + + Niet-blokkerende waarschuwing + + Met deze ongeplande activiteit kom je ongeveer{" "} + {Math.abs(previewMeter.remainingBudget ?? 0)} punten boven je dagbudget uit. + Je kunt nog steeds opslaan, maar dit helpt je later beter begrijpen waarom je dag zwaarder uitviel. + + + ) : null} +
+
+
+ + +
+

+ {isPending + ? "Je ongeplande activiteit wordt opgeslagen..." + : "Deze activiteit wordt vandaag toegevoegd met bron `ongepland` en status `uitgevoerd`."} +

+ + +
+ + ); +} diff --git a/components/planning/energy-meter-card.tsx b/components/planning/energy-meter-card.tsx index 91c7962..7bb51ce 100644 --- a/components/planning/energy-meter-card.tsx +++ b/components/planning/energy-meter-card.tsx @@ -36,10 +36,10 @@ function getMeterDescription(meter: PlanningMeterSnapshot) { } if (meter.isOverBudget) { - return "Je planning zit boven je dagbudget. Dat is een signaal om eventueel iets te verschuiven of lichter te maken, niet om te blokkeren."; + return "Je dagtotaal zit boven je dagbudget. Dat is een signaal om eventueel iets te verschuiven of lichter te maken, niet om te blokkeren."; } - return "De meter blijft bewust eenvoudig: punten volgen uit duur en impact van je activiteiten."; + return "De meter blijft bewust eenvoudig: punten volgen uit duur en impact van alle activiteiten die vandaag in beeld zijn."; } export function EnergyMeterCard({ @@ -57,8 +57,8 @@ export function EnergyMeterCard({

{meter.dailyBudget === null - ? `${meter.plannedPoints} geplande punten` - : `${meter.plannedPoints} van ${meter.dailyBudget} punten gepland`} + ? `${meter.totalPoints} punten in beeld` + : `${meter.totalPoints} van ${meter.dailyBudget} punten in beeld`} @@ -77,7 +77,7 @@ export function EnergyMeterCard({ aria-valuetext={ meter.dailyBudget === null ? "Nog geen dagbudget beschikbaar" - : `${meter.plannedPoints} van ${meter.dailyBudget} punten gepland` + : `${meter.totalPoints} van ${meter.dailyBudget} punten in beeld` } >
{meter.dailyBudget !== null && meter.isOverBudget ? ( - - Je zit boven je dagbudget - - Je planning komt nu {Math.abs(meter.remainingBudget ?? 0)} punten boven het dagbudget uit. + + Je zit boven je dagbudget + + Je dagtotaal komt nu {Math.abs(meter.remainingBudget ?? 0)} punten boven het dagbudget uit. Je kunt nog steeds doorgaan, maar dit is een goed moment om iets te schrappen, te verkorten of later te doen. diff --git a/components/planning/today-activities-list.tsx b/components/planning/today-activities-list.tsx index b4cb5d2..c2f57de 100644 --- a/components/planning/today-activities-list.tsx +++ b/components/planning/today-activities-list.tsx @@ -81,6 +81,22 @@ function getStatusBadgeClassName(value: ActivityRecord["status"]) { return "bg-secondary text-secondary-foreground"; } +function formatSourceLabel(value: ActivityRecord["source"]) { + if (value === "ad_hoc") { + return "Ongepland"; + } + + return "Gepland"; +} + +function getSourceBadgeClassName(value: ActivityRecord["source"]) { + if (value === "ad_hoc") { + return "bg-primary text-primary-foreground"; + } + + return "bg-muted text-muted-foreground"; +} + export function TodayActivitiesList({ activities, categories, @@ -90,18 +106,18 @@ export function TodayActivitiesList({

- Vandaag gepland + Vandaag in beeld

{activities.length === 0 - ? "Nog geen activiteiten gepland" + ? "Nog geen activiteiten toegevoegd" : `${activities.length} ${activities.length === 1 ? "activiteit" : "activiteiten"}`}
{activities.length === 0 ? ( - Je dag is nog leeg. Plan eerst een kleine concrete activiteit om de flow op gang te brengen. + Je dag is nog leeg. Plan eerst iets kleins of voeg later een ongeplande activiteit toe als je dag anders liep dan verwacht. ) : ( activities.map((activity) => ( @@ -116,14 +132,24 @@ export function TodayActivitiesList({ {getCategoryLabel(categories, activity.categoryId)}

- - {formatStatusLabel(activity.status)} - +
+ + {formatSourceLabel(activity.source)} + + + {formatStatusLabel(activity.status)} + +
diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index fee9068..8a08a0c 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -92,14 +92,14 @@ Status: `ST-301`, `ST-302`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien. -Status: `ST-401` en `ST-402` zijn inmiddels gerealiseerd in de app. De volgende -logische stap ligt nu in `ST-403`. +Status: `ST-401`, `ST-402` en `ST-403` zijn inmiddels gerealiseerd in de app. De volgende +logische stap ligt nu in `ST-404`. | Story ID | Titel | Type | Definition of done | | --- | --- | --- | --- | | ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Afgerond: activiteiten van vandaag kunnen direct tussen de vier statussen wisselen | | ST-402 | Evaluatievelden toevoegen | UI | Afgerond: skip-reden en toelichting verschijnen passend per status en worden opgeslagen | -| ST-403 | Ongeplande activiteiten ondersteunen | Build | Ongeplande activiteit telt mee in werkelijke totalen | +| ST-403 | Ongeplande activiteiten ondersteunen | Build | Afgerond: ongeplande activiteit kan als ad-hoc item worden opgeslagen en telt mee in het dagtotaal | | 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 | diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts index 983005d..2c8af32 100644 --- a/lib/feedback/status-messages.ts +++ b/lib/feedback/status-messages.ts @@ -69,6 +69,11 @@ const planningStatusToasts: Record = { title: "Activiteit gepland", message: "Je activiteit staat nu in je dagplanning van vandaag.", }, + "ad-hoc-activity-saved": { + variant: "success", + title: "Ongeplande activiteit toegevoegd", + message: "Deze activiteit staat nu ook in je daglijst van vandaag.", + }, "activity-status-saved": { variant: "success", title: "Activiteit bijgewerkt", @@ -88,6 +93,12 @@ const planningErrorToasts: Record = { message: "Controleer naam, categorie, duur, impact en prioriteit en probeer het opnieuw.", }, + "invalid-ad-hoc-activity-input": { + variant: "error", + title: "Ongeplande activiteit niet opgeslagen", + message: + "Controleer naam, categorie, duur en impact en probeer het opnieuw.", + }, "invalid-activity-status": { variant: "error", title: "Status niet opgeslagen", @@ -109,6 +120,11 @@ const planningErrorToasts: Record = { title: "Evaluatie niet opgeslagen", message: "De extra context bij deze activiteit kon niet worden opgeslagen.", }, + "ad-hoc-activity-failed": { + variant: "error", + title: "Ongeplande activiteit niet opgeslagen", + message: "De ongeplande activiteit kon niet worden toegevoegd. Probeer het opnieuw.", + }, }; export function getDashboardStatusToast(status: string | null): StatusToast | null { diff --git a/lib/planning/meter.test.ts b/lib/planning/meter.test.ts index cc4af33..a4ead90 100644 --- a/lib/planning/meter.test.ts +++ b/lib/planning/meter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - calculatePlannedPointsTotal, + calculateActivityPointsTotal, calculatePlanningMeterSnapshot, deriveActivityEnergyPoints, } from "./meter"; @@ -38,7 +38,7 @@ describe("deriveActivityEnergyPoints", () => { describe("calculatePlanningMeterSnapshot", () => { it("somt punten van activiteiten op", () => { expect( - calculatePlannedPointsTotal([ + calculateActivityPointsTotal([ { durationMinutes: 30, impactLevel: "midden", status: "planned" }, { durationMinutes: 90, impactLevel: "laag", status: "planned" }, ]), @@ -54,7 +54,7 @@ describe("calculatePlanningMeterSnapshot", () => { 8, ); - expect(snapshot.plannedPoints).toBe(6); + expect(snapshot.totalPoints).toBe(6); expect(snapshot.remainingBudget).toBe(2); expect(snapshot.progressPercent).toBe(75); expect(snapshot.isOverBudget).toBe(false); @@ -66,7 +66,7 @@ describe("calculatePlanningMeterSnapshot", () => { null, ); - expect(snapshot.plannedPoints).toBe(2); + expect(snapshot.totalPoints).toBe(2); expect(snapshot.dailyBudget).toBeNull(); expect(snapshot.remainingBudget).toBeNull(); expect(snapshot.progressPercent).toBeNull(); @@ -81,7 +81,7 @@ describe("calculatePlanningMeterSnapshot", () => { 6, ); - expect(snapshot.plannedPoints).toBe(9); + expect(snapshot.totalPoints).toBe(9); expect(snapshot.remainingBudget).toBe(-3); expect(snapshot.isOverBudget).toBe(true); expect(snapshot.progressPercent).toBe(100); diff --git a/lib/planning/meter.ts b/lib/planning/meter.ts index a88b882..d565d77 100644 --- a/lib/planning/meter.ts +++ b/lib/planning/meter.ts @@ -5,7 +5,7 @@ import type { } from "@/lib/planning/types"; export type PlanningMeterSnapshot = { - plannedPoints: number; + totalPoints: number; activityCount: number; dailyBudget: number | null; remainingBudget: number | null; @@ -59,7 +59,7 @@ export function deriveActivityEnergyPoints(input: ActivityMeterInput): number { ); } -export function calculatePlannedPointsTotal( +export function calculateActivityPointsTotal( activities: Pick[], ): number { return activities.reduce( @@ -72,11 +72,11 @@ export function calculatePlanningMeterSnapshot( activities: Pick[], dailyBudget: number | null, ): PlanningMeterSnapshot { - const plannedPoints = calculatePlannedPointsTotal(activities); + const totalPoints = calculateActivityPointsTotal(activities); if (dailyBudget === null) { return { - plannedPoints, + totalPoints, activityCount: activities.length, dailyBudget: null, remainingBudget: null, @@ -86,11 +86,11 @@ export function calculatePlanningMeterSnapshot( }; } - const remainingBudget = dailyBudget - plannedPoints; - const progressRatio = dailyBudget > 0 ? plannedPoints / dailyBudget : 0; + const remainingBudget = dailyBudget - totalPoints; + const progressRatio = dailyBudget > 0 ? totalPoints / dailyBudget : 0; return { - plannedPoints, + totalPoints, activityCount: activities.length, dailyBudget, remainingBudget, diff --git a/lib/planning/service.ts b/lib/planning/service.ts index eb711a9..0c212ae 100644 --- a/lib/planning/service.ts +++ b/lib/planning/service.ts @@ -1,6 +1,7 @@ import { getAuthenticatedUser } from "@/lib/auth/session"; import type { ActivityCategory, + CreateAdHocActivitySubmission, CreateActivitySubmission, ActivityImpactLevel, ActivityPriorityLevel, @@ -328,6 +329,51 @@ export async function createActivityForTodayForCurrentUser( return mapActivityRow(data); } +export async function createAdHocActivityForTodayForCurrentUser( + submission: CreateAdHocActivitySubmission, +): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + throw new Error("Er is geen ingelogde gebruiker beschikbaar."); + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + throw new Error("Profielbundle ontbreekt voor de huidige gebruiker."); + } + + const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const supabase = await createClient(); + + await assertCategoryExists(supabase, submission.categoryId); + + const { data, error } = await supabase + .from("activities") + .insert({ + user_id: user.id, + activity_date: activityDate, + source: "ad_hoc", + status: "completed", + name: submission.name, + category_id: submission.categoryId, + duration_minutes: submission.durationMinutes, + impact_level: submission.impactLevel, + priority_level: "normaal", + skip_reason_id: null, + notes: null, + }) + .select(ACTIVITY_COLUMNS) + .single(); + + if (error) { + throw new Error(`Ongeplande activiteit kon niet worden opgeslagen: ${error.message}`); + } + + return mapActivityRow(data); +} + export async function updateActivityStatusForTodayForCurrentUser( submission: UpdateActivityStatusSubmission, ): Promise { diff --git a/lib/planning/types.ts b/lib/planning/types.ts index cb0ee82..f6e5daa 100644 --- a/lib/planning/types.ts +++ b/lib/planning/types.ts @@ -53,6 +53,13 @@ export type CreateActivitySubmission = { priorityLevel: ActivityPriorityLevel; }; +export type CreateAdHocActivitySubmission = { + name: string; + categoryId: string; + durationMinutes: number; + impactLevel: ActivityImpactLevel; +}; + export type UpdateActivityStatusSubmission = { activityId: string; status: ActivityStatus;