diff --git a/README.md b/README.md index 7f408a4..a9fef24 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - statusflows voor activiteiten van vandaag (`gepland`, `uitgevoerd`, `overgeslagen`, `aangepast`) - contextuele evaluatievelden voor overgeslagen en aangepaste activiteiten - dagoverzicht op planning met gepland versus werkelijk en statusverdeling +- autocomplete op basis van eerdere eigen activiteiten voor sneller hergebruik in planning - energiemeter met lopend totaal ten opzichte van het dagbudget - niet-blokkerende waarschuwing bij budgetoverschrijding in planning en dashboard - eerste unit tests voor budget- en meterlogica via `Vitest` @@ -134,6 +135,6 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-303` Autocomplete op eerdere activiteiten toevoegen -2. `ST-105` RLS-policy tests en hardening afronden -3. `npm run test` toevoegen aan CI +1. `ST-105` RLS-policy tests en hardening afronden +2. logging en monitoring toevoegen +3. rate limiting op kritieke mutaties diff --git a/app/planning/page.tsx b/app/planning/page.tsx index 2940764..9e5b70a 100644 --- a/app/planning/page.tsx +++ b/app/planning/page.tsx @@ -94,11 +94,13 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps) diff --git a/components/planning/activity-form.tsx b/components/planning/activity-form.tsx index 67124f6..ed70955 100644 --- a/components/planning/activity-form.tsx +++ b/components/planning/activity-form.tsx @@ -3,6 +3,7 @@ import { useActionState, useMemo, useState } from "react"; import { createActivityAction } from "@/app/planning/actions"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ActivitySuggestionList } from "@/components/planning/activity-suggestion-list"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -27,16 +28,22 @@ import { ACTIVITY_PRIORITY_OPTIONS, } from "@/lib/planning/form-options"; import { calculatePlanningMeterSnapshot, deriveActivityEnergyPoints } from "@/lib/planning/meter"; -import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types"; +import type { ActivityCategory, ActivityRecord, ActivitySuggestion } from "@/lib/planning/types"; import { cn } from "@/lib/utils"; type ActivityFormProps = { categories: ActivityCategory[]; activities: ActivityRecord[]; + suggestions: ActivitySuggestion[]; dailyBudget: number | null; }; -export function ActivityForm({ categories, activities, dailyBudget }: ActivityFormProps) { +export function ActivityForm({ + categories, + activities, + suggestions, + dailyBudget, +}: ActivityFormProps) { const [, formAction, isPending] = useActionState(createActivityAction, null); const [name, setName] = useState(""); const [categoryId, setCategoryId] = useState(categories[0]?.id ?? ""); @@ -83,6 +90,14 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo ); }, [activities, dailyBudget, durationMinutes, impactLevel, previewPoints]); + function applySuggestion(suggestion: ActivitySuggestion) { + setName(suggestion.name); + setCategoryId(suggestion.categoryId); + setDurationMinutes(String(suggestion.durationMinutes)); + setImpactLevel(suggestion.impactLevel); + setPriorityLevel(suggestion.priorityLevel); + } + return (
@@ -117,6 +132,13 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo value={name} onChange={(event) => setName(event.target.value)} /> +
diff --git a/components/planning/activity-suggestion-list.tsx b/components/planning/activity-suggestion-list.tsx new file mode 100644 index 0000000..195061d --- /dev/null +++ b/components/planning/activity-suggestion-list.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useMemo } from "react"; +import type { ActivityCategory, ActivitySuggestion } from "@/lib/planning/types"; +import { cn } from "@/lib/utils"; + +type ActivitySuggestionListProps = { + categories: ActivityCategory[]; + suggestions: ActivitySuggestion[]; + query: string; + disabled?: boolean; + showPriority?: boolean; + onSelect: (suggestion: ActivitySuggestion) => void; +}; + +function normalizeQuery(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("nl-NL"); +} + +function getCategoryLabel(categories: ActivityCategory[], categoryId: string) { + return categories.find((category) => category.id === categoryId)?.labelNl ?? "Onbekende categorie"; +} + +function formatImpactLabel(value: ActivitySuggestion["impactLevel"]) { + if (value === "laag") { + return "Lage impact"; + } + + if (value === "hoog") { + return "Hoge impact"; + } + + return "Middenimpact"; +} + +function formatPriorityLabel(value: ActivitySuggestion["priorityLevel"]) { + if (value === "laag") { + return "Lage prioriteit"; + } + + if (value === "hoog") { + return "Hoge prioriteit"; + } + + return "Normale prioriteit"; +} + +export function ActivitySuggestionList({ + categories, + suggestions, + query, + disabled = false, + showPriority = true, + onSelect, +}: ActivitySuggestionListProps) { + const normalizedQuery = normalizeQuery(query); + const visibleSuggestions = useMemo(() => { + const filtered = normalizedQuery + ? suggestions.filter((suggestion) => + normalizeQuery(suggestion.name).includes(normalizedQuery), + ) + : suggestions; + + return filtered.slice(0, normalizedQuery ? 5 : 4); + }, [normalizedQuery, suggestions]); + + if (visibleSuggestions.length === 0) { + if (!normalizedQuery) { + return null; + } + + return ( +

+ Geen eerdere match gevonden. Je kunt deze activiteit gewoon nieuw opslaan. +

+ ); + } + + return ( +
+

+ {normalizedQuery ? "Vergelijkbare eerdere activiteiten" : "Snel hergebruiken"} +

+
+ {visibleSuggestions.map((suggestion) => ( + + ))} +
+
+ ); +} diff --git a/components/planning/ad-hoc-activity-form.tsx b/components/planning/ad-hoc-activity-form.tsx index c906856..c928953 100644 --- a/components/planning/ad-hoc-activity-form.tsx +++ b/components/planning/ad-hoc-activity-form.tsx @@ -3,6 +3,7 @@ import { useActionState, useMemo, useState } from "react"; import { createAdHocActivityAction } from "@/app/planning/actions"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ActivitySuggestionList } from "@/components/planning/activity-suggestion-list"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -28,18 +29,20 @@ import { calculatePlanningMeterSnapshot, deriveActivityEnergyPoints, } from "@/lib/planning/meter"; -import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types"; +import type { ActivityCategory, ActivityRecord, ActivitySuggestion } from "@/lib/planning/types"; import { cn } from "@/lib/utils"; type AdHocActivityFormProps = { categories: ActivityCategory[]; activities: ActivityRecord[]; + suggestions: ActivitySuggestion[]; dailyBudget: number | null; }; export function AdHocActivityForm({ categories, activities, + suggestions, dailyBudget, }: AdHocActivityFormProps) { const [, formAction, isPending] = useActionState(createAdHocActivityAction, null); @@ -87,6 +90,13 @@ export function AdHocActivityForm({ ); }, [activities, dailyBudget, durationMinutes, impactLevel, previewPoints]); + function applySuggestion(suggestion: ActivitySuggestion) { + setName(suggestion.name); + setCategoryId(suggestion.categoryId); + setDurationMinutes(String(suggestion.durationMinutes)); + setImpactLevel(suggestion.impactLevel); + } + return ( @@ -121,6 +131,14 @@ export function AdHocActivityForm({ value={name} onChange={(event) => setName(event.target.value)} /> +
diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index 769b070..26ed3e0 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -78,13 +78,13 @@ Status: `ST-201`, `ST-202`, `ST-203`, `ST-204` en `ST-205` zijn inmiddels gereal Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel. -Status: `ST-301`, `ST-302`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `EPIC-05`. +Status: `ST-301`, `ST-302`, `ST-303`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in de app. De dagplanningloop voor release 1 is daarmee functioneel rond. | Story ID | Titel | Type | Definition of done | | --- | --- | --- | --- | | ST-301 | Datamodel voor activiteiten implementeren | Build | Afgerond: migraties en seed-data voor categorieën en skip-redenen zijn aanwezig | | ST-302 | Planningformulier bouwen | UI | Afgerond: 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-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Afgerond: planning en ad-hocformulier bieden nu snelle hergebruiksuggesties uit eigen historie | | ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Afgerond: totaal update direct na elke wijziging | | ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Afgerond: gebruiker krijgt feedback maar behoudt regie | diff --git a/lib/planning/service.ts b/lib/planning/service.ts index e5e11b2..23a2e7a 100644 --- a/lib/planning/service.ts +++ b/lib/planning/service.ts @@ -1,6 +1,7 @@ import { getAuthenticatedUser } from "@/lib/auth/session"; import { getLocalDateForTimezone } from "@/lib/dates"; import { calculateDayOverviewSnapshot } from "@/lib/planning/day-overview"; +import { buildActivitySuggestions } from "@/lib/planning/suggestions"; import type { ActivityCategory, CreateAdHocActivitySubmission, @@ -130,6 +131,26 @@ async function readActivitiesByDate( return (data ?? []).map(mapActivityRow); } +async function readRecentActivitiesForSuggestions( + supabase: SupabaseServerClient, + userId: string, + currentActivityDate: string, +): Promise { + const { data, error } = await supabase + .from("activities") + .select(ACTIVITY_COLUMNS) + .eq("user_id", userId) + .neq("activity_date", currentActivityDate) + .order("updated_at", { ascending: false }) + .limit(60); + + if (error) { + throw new Error(`Activiteitensuggesties konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapActivityRow); +} + async function assertCategoryExists( supabase: SupabaseServerClient, categoryId: string, @@ -250,19 +271,24 @@ export async function getPlanningPageDataForCurrentUser(): Promise { + it("dedupliceert dezelfde activiteit en telt gebruik", () => { + const suggestions = buildActivitySuggestions([ + { + name: " Was opvouwen ", + categoryId: "cat-a", + durationMinutes: 30, + impactLevel: "laag", + priorityLevel: "normaal", + createdAt: "2026-04-18T08:00:00.000Z", + updatedAt: "2026-04-18T08:00:00.000Z", + }, + { + name: "Was opvouwen", + categoryId: "cat-a", + durationMinutes: 30, + impactLevel: "laag", + priorityLevel: "normaal", + createdAt: "2026-04-19T08:00:00.000Z", + updatedAt: "2026-04-19T09:00:00.000Z", + }, + ]); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + name: "Was opvouwen", + categoryId: "cat-a", + durationMinutes: 30, + impactLevel: "laag", + priorityLevel: "normaal", + useCount: 2, + lastUsedAt: "2026-04-19T09:00:00.000Z", + }); + }); + + it("sorteert op meest recent gebruik en houdt verschillende varianten apart", () => { + const suggestions = buildActivitySuggestions([ + { + name: "Boodschappen", + categoryId: "cat-a", + durationMinutes: 45, + impactLevel: "midden", + priorityLevel: "hoog", + createdAt: "2026-04-17T08:00:00.000Z", + updatedAt: "2026-04-17T08:00:00.000Z", + }, + { + name: "Telefoontje", + categoryId: "cat-b", + durationMinutes: 15, + impactLevel: "laag", + priorityLevel: "normaal", + createdAt: "2026-04-19T10:00:00.000Z", + updatedAt: "2026-04-19T10:00:00.000Z", + }, + { + name: "Boodschappen", + categoryId: "cat-a", + durationMinutes: 45, + impactLevel: "midden", + priorityLevel: "laag", + createdAt: "2026-04-18T08:00:00.000Z", + updatedAt: "2026-04-18T08:00:00.000Z", + }, + ]); + + expect(suggestions).toHaveLength(3); + expect(suggestions[0].name).toBe("Telefoontje"); + expect(suggestions[1].priorityLevel).toBe("laag"); + expect(suggestions[2].priorityLevel).toBe("hoog"); + }); +}); diff --git a/lib/planning/suggestions.ts b/lib/planning/suggestions.ts new file mode 100644 index 0000000..3db9b80 --- /dev/null +++ b/lib/planning/suggestions.ts @@ -0,0 +1,82 @@ +import type { ActivityRecord, ActivitySuggestion } from "./types"; + +type ActivitySuggestionInput = Pick< + ActivityRecord, + | "name" + | "categoryId" + | "durationMinutes" + | "impactLevel" + | "priorityLevel" + | "createdAt" + | "updatedAt" +>; + +function normalizeSuggestionName(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("nl-NL"); +} + +function cleanSuggestionName(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +function buildSuggestionKey(activity: ActivitySuggestionInput) { + return [ + normalizeSuggestionName(activity.name), + activity.categoryId, + activity.durationMinutes, + activity.impactLevel, + activity.priorityLevel, + ].join("::"); +} + +function compareSuggestions(a: ActivitySuggestion, b: ActivitySuggestion) { + if (a.lastUsedAt !== b.lastUsedAt) { + return b.lastUsedAt.localeCompare(a.lastUsedAt); + } + + if (a.useCount !== b.useCount) { + return b.useCount - a.useCount; + } + + return a.name.localeCompare(b.name, "nl-NL"); +} + +export function buildActivitySuggestions( + activities: ActivitySuggestionInput[], + limit = 12, +): ActivitySuggestion[] { + const suggestions = new Map(); + + for (const activity of activities) { + const key = buildSuggestionKey(activity); + const lastUsedAt = activity.updatedAt ?? activity.createdAt; + const existing = suggestions.get(key); + + if (!existing) { + suggestions.set(key, { + id: key, + name: cleanSuggestionName(activity.name), + categoryId: activity.categoryId, + durationMinutes: activity.durationMinutes, + impactLevel: activity.impactLevel, + priorityLevel: activity.priorityLevel, + lastUsedAt, + useCount: 1, + }); + continue; + } + + existing.useCount += 1; + + if (lastUsedAt.localeCompare(existing.lastUsedAt) > 0) { + existing.lastUsedAt = lastUsedAt; + existing.name = cleanSuggestionName(activity.name); + existing.categoryId = activity.categoryId; + existing.durationMinutes = activity.durationMinutes; + existing.impactLevel = activity.impactLevel; + existing.priorityLevel = activity.priorityLevel; + } + } + + return Array.from(suggestions.values()).sort(compareSuggestions).slice(0, limit); +} diff --git a/lib/planning/types.ts b/lib/planning/types.ts index e2e396f..32feef6 100644 --- a/lib/planning/types.ts +++ b/lib/planning/types.ts @@ -46,6 +46,17 @@ export type ActivityRecord = { updatedAt: string; }; +export type ActivitySuggestion = { + id: string; + name: string; + categoryId: string; + durationMinutes: number; + impactLevel: ActivityImpactLevel; + priorityLevel: ActivityPriorityLevel; + lastUsedAt: string; + useCount: number; +}; + export type CreateActivitySubmission = { name: string; categoryId: string; @@ -84,5 +95,6 @@ export type PlanningPageData = { categories: ActivityCategory[]; skipReasons: SkipReason[]; activities: ActivityRecord[]; + suggestions: ActivitySuggestion[]; dayOverview: DayOverviewSnapshot; };