Implement ST-303 activity autocomplete

This commit is contained in:
Janpeter Visser 2026-04-19 10:49:53 +02:00
parent 307686de68
commit 8ab83205dd
10 changed files with 355 additions and 10 deletions

View file

@ -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

View file

@ -94,11 +94,13 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
<ActivityForm
categories={planningPageData.categories}
activities={planningPageData.activities}
suggestions={planningPageData.suggestions}
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
/>
<AdHocActivityForm
categories={planningPageData.categories}
activities={planningPageData.activities}
suggestions={planningPageData.suggestions}
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
/>
</div>

View file

@ -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<string>(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 (
<form action={formAction} className="space-y-6" aria-busy={isPending}>
<input type="hidden" name="categoryId" value={categoryId} />
@ -117,6 +132,13 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo
value={name}
onChange={(event) => setName(event.target.value)}
/>
<ActivitySuggestionList
categories={categories}
suggestions={suggestions}
query={name}
disabled={isPending}
onSelect={applySuggestion}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">

View file

@ -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 (
<p className="text-sm leading-7 text-muted-foreground">
Geen eerdere match gevonden. Je kunt deze activiteit gewoon nieuw opslaan.
</p>
);
}
return (
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{normalizedQuery ? "Vergelijkbare eerdere activiteiten" : "Snel hergebruiken"}
</p>
<div className="grid gap-2">
{visibleSuggestions.map((suggestion) => (
<button
key={suggestion.id}
type="button"
disabled={disabled}
onClick={() => onSelect(suggestion)}
className={cn(
"rounded-[1.1rem] border border-border/65 bg-background/78 px-4 py-3 text-left transition hover:border-primary/35 hover:bg-card disabled:pointer-events-none disabled:opacity-60",
)}
>
<span className="block text-sm font-semibold text-foreground">{suggestion.name}</span>
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
{getCategoryLabel(categories, suggestion.categoryId)} · {suggestion.durationMinutes} min ·{" "}
{formatImpactLabel(suggestion.impactLevel)}
{showPriority ? ` · ${formatPriorityLabel(suggestion.priorityLevel)}` : ""}
{suggestion.useCount > 1 ? ` · ${suggestion.useCount}× gebruikt` : ""}
</span>
</button>
))}
</div>
</div>
);
}

View file

@ -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 (
<form action={formAction} className="space-y-6" aria-busy={isPending}>
<input type="hidden" name="categoryId" value={categoryId} />
@ -121,6 +131,14 @@ export function AdHocActivityForm({
value={name}
onChange={(event) => setName(event.target.value)}
/>
<ActivitySuggestionList
categories={categories}
suggestions={suggestions}
query={name}
disabled={isPending}
showPriority={false}
onSelect={applySuggestion}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">

View file

@ -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 |

View file

@ -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<ActivityRecord[]> {
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<PlanningPageD
return null;
}
const [categories, skipReasons, activitiesStatus] = await Promise.all([
const currentActivityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
const supabase = await createClient();
const [categories, skipReasons, activitiesStatus, recentActivities] = await Promise.all([
listActivityCategories(),
listSkipReasons(),
getTodayActivitiesForCurrentUser(),
readRecentActivitiesForSuggestions(supabase, user.id, currentActivityDate),
]);
return {
timezone: profileBundle.profile.timezone,
activityDate:
activitiesStatus?.activityDate ?? getLocalDateForTimezone(profileBundle.profile.timezone),
activitiesStatus?.activityDate ?? currentActivityDate,
categories,
skipReasons,
activities: activitiesStatus?.activities ?? [],
suggestions: buildActivitySuggestions(recentActivities),
dayOverview: calculateDayOverviewSnapshot(activitiesStatus?.activities ?? []),
};
}

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { buildActivitySuggestions } from "./suggestions";
describe("buildActivitySuggestions", () => {
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");
});
});

View file

@ -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<string, ActivitySuggestion>();
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);
}

View file

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