perf: batch scheduling reconciliation and add widget

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 60b01e5a16
commit b9f557e95b
7 changed files with 290 additions and 32 deletions

View File

@@ -22,7 +22,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"

View File

@@ -0,0 +1,120 @@
import { useCallback } from "react";
import { CalendarClock } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listSchedulingRequests,
type SchedulingRequest
} from "../../api/scheduling";
export default function SchedulingRequestsWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const includeDrafts = configuration.includeDrafts === true;
const load = useCallback(async () => {
const response = await listSchedulingRequests(settings);
return response.requests
.filter(
(request) =>
request.status === "collecting"
|| (includeDrafts && request.status === "draft")
)
.sort(compareRequests)
.slice(0, maxItems);
}, [includeDrafts, maxItems, settings]);
const { data: requests, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading scheduling requests">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No scheduling requests are awaiting responses."
items={(requests ?? []).map((request) => ({
id: request.id,
title: request.title,
detail: responseLabel(request),
meta: deadlineLabel(request),
leading: <CalendarClock size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={request.status}
label={request.status === "collecting" ? "Open" : "Draft"}
/>
),
to: "/scheduling"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/scheduling">
Open scheduling
</Link>
</div>
</LoadingFrame>
);
}
function compareRequests(
left: SchedulingRequest,
right: SchedulingRequest
): number {
if (left.status !== right.status) {
return left.status === "collecting" ? -1 : 1;
}
const leftDeadline = left.deadline_at
? new Date(left.deadline_at).getTime()
: Number.POSITIVE_INFINITY;
const rightDeadline = right.deadline_at
? new Date(right.deadline_at).getTime()
: Number.POSITIVE_INFINITY;
return (
leftDeadline - rightDeadline
|| new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
);
}
function responseLabel(request: SchedulingRequest): string {
const responded = request.participant_aggregate.status_counts.responded ?? 0;
return `${responded} of ${request.participant_aggregate.total} responded`;
}
function deadlineLabel(request: SchedulingRequest): string {
if (!request.deadline_at) return `${request.slots.length} options`;
return `Due ${new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short"
}).format(new Date(request.deadline_at))}`;
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -1,5 +1,9 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import SchedulingRequestsWidget from "./features/scheduling/SchedulingRequestsWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/scheduling.css";
@@ -7,6 +11,50 @@ const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage")
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const scheduleRead = ["scheduling:schedule:read"];
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "scheduling.open-requests",
surfaceId: "scheduling.widget.open-requests",
title: "Scheduling requests",
description: "Open scheduling polls and their response progress.",
moduleId: "scheduling",
category: "Planning",
order: 45,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: scheduleRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
includeDrafts: false
},
configurationFields: [
{
id: "maxItems",
label: "Maximum requests",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "includeDrafts",
label: "Include drafts",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(SchedulingRequestsWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const schedulingModule: PlatformWebModule = {
id: "scheduling",
@@ -15,6 +63,15 @@ export const schedulingModule: PlatformWebModule = {
dependencies: ["poll"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations,
viewSurfaces: [
{
id: "scheduling.widget.open-requests",
moduleId: "scheduling",
kind: "section",
label: "Scheduling requests widget",
order: 45
}
],
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
@@ -25,7 +82,10 @@ export const schedulingModule: PlatformWebModule = {
order: 10,
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
}
]
],
uiCapabilities: {
"dashboard.widgets": schedulingDashboardWidgets
}
};
export default schedulingModule;