Implement ST-403 ad hoc activities

This commit is contained in:
Janpeter Visser 2026-04-19 10:04:55 +02:00
parent 57ade6a772
commit a8366932a0
13 changed files with 491 additions and 51 deletions

View file

@ -69,6 +69,11 @@ const planningStatusToasts: Record<string, StatusToast> = {
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<string, StatusToast> = {
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<string, StatusToast> = {
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 {

View file

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

View file

@ -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<ActivityRecord, "durationMinutes" | "impactLevel" | "status">[],
): number {
return activities.reduce(
@ -72,11 +72,11 @@ export function calculatePlanningMeterSnapshot(
activities: Pick<ActivityRecord, "durationMinutes" | "impactLevel" | "status">[],
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,

View file

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

View file

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