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 (
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;
};