feat(assistant): add approval inbox and e2e hardening

This commit is contained in:
2026-03-29 10:10:59 +02:00
parent 4f48afe7b4
commit beae1a5d6e
12 changed files with 2482 additions and 331 deletions
@@ -75,6 +75,33 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
export type ViewMode = "resource" | "project";
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
const savedPrefs = readAppPreferences();
const next: TimelineFilters = {
...DEFAULT_FILTERS,
hideCompletedProjects: savedPrefs.hideCompletedProjects,
showPlaceholders: savedPrefs.showDemandProjects,
};
const eids = searchParams.get("eids");
if (eids) next.eids = eids.split(",").filter(Boolean);
const projectIds = searchParams.get("projectIds");
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
const chapters = searchParams.get("chapters");
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
const clientIds = searchParams.get("clientIds");
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
const countryCodes = searchParams.get("countryCodes");
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
if (eids || projectIds) {
next.showDrafts = true;
next.hideCompletedProjects = false;
}
return next;
}
// ─── Derived resource type used throughout the timeline ─────────────────────
export type ResourceBrief = {
id: string;
@@ -218,77 +245,31 @@ export function TimelineProvider({
const viewEnd = addDays(viewStart, viewDays);
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
const [filters, setFilters] = useState<TimelineFilters>(() => {
const savedPrefs = readAppPreferences();
const base: TimelineFilters = {
...DEFAULT_FILTERS,
hideCompletedProjects: savedPrefs.hideCompletedProjects,
showPlaceholders: savedPrefs.showDemandProjects,
};
const eids = searchParams.get("eids");
if (eids) base.eids = eids.split(",").filter(Boolean);
const projectIds = searchParams.get("projectIds");
if (projectIds) base.projectIds = projectIds.split(",").filter(Boolean);
const chapters = searchParams.get("chapters");
if (chapters) base.chapters = chapters.split(",").filter(Boolean);
const clientIds = searchParams.get("clientIds");
if (clientIds) base.clientIds = clientIds.split(",").filter(Boolean);
const countryCodes = searchParams.get("countryCodes");
if (countryCodes) base.countryCodes = countryCodes.split(",").filter(Boolean);
// If URL params specify filters, also show drafts and don't hide completed
if (eids || projectIds) {
base.showDrafts = true;
base.hideCompletedProjects = false;
}
return base;
});
// Track whether this is the initial mount (URL params already applied via useState initializers)
const isInitialMount = useRef(true);
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
// Sync filters/viewStart/viewDays when URL search params change AFTER initial mount
// (e.g. when the AI assistant calls router.push("/timeline?eids=...") while already on /timeline)
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
// Update viewStart if param changed
const spStart = searchParams.get("startDate");
if (spStart) {
const d = new Date(spStart);
if (!isNaN(d.getTime())) setViewStart(d);
}
setViewStart(() => {
if (spStart) {
const d = new Date(spStart);
if (!isNaN(d.getTime())) return d;
}
return addDays(today, -30);
});
// Update viewDays if param changed
const spDays = searchParams.get("days");
if (spDays) {
const n = parseInt(spDays, 10);
if (n > 0 && n <= 365) setViewDays(n);
}
setViewDays(() => {
if (spDays) {
const n = parseInt(spDays, 10);
if (n > 0 && n <= 365) return n;
}
return 180;
});
// Update filters if any filter params present
const eids = searchParams.get("eids");
const projectIds = searchParams.get("projectIds");
const chapters = searchParams.get("chapters");
const clientIds = searchParams.get("clientIds");
const countryCodes = searchParams.get("countryCodes");
if (eids || projectIds || chapters || clientIds || countryCodes) {
setFilters((prev) => {
const next = { ...prev };
if (eids) next.eids = eids.split(",").filter(Boolean);
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
if (eids || projectIds) {
next.showDrafts = true;
next.hideCompletedProjects = false;
}
return next;
});
}
}, [searchParams]);
setFilters(buildTimelineFiltersFromSearchParams(searchParams));
}, [searchParams, today]);
const [filterOpen, setFilterOpen] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("resource");
@@ -301,7 +282,9 @@ export function TimelineProvider({
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
// ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
const mountedRef = useRef(false);
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
@@ -314,17 +297,29 @@ export function TimelineProvider({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
) as {
data: TimelineEntriesView | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
const assignments = entriesView?.assignments ?? [];
const demands = entriesView?.demands ?? [];
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
const {
data: vacationEntries = [],
refetch: refetchVacations,
} = trpc.vacation.list.useQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
const {
data: holidayOverlayEntries = [],
refetch: refetchHolidayOverlays,
} = trpc.timeline.getHolidayOverlays.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
@@ -337,6 +332,17 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
useEffect(() => {
if (mountedRef.current) return;
mountedRef.current = true;
// Harden client-side route transitions: the timeline must actively refresh
// its core read models once on mount instead of relying on a prefetched shell.
void refetchEntriesView();
void refetchVacations();
void refetchHolidayOverlays();
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
const mergedEntries = [...(vacationEntries as VacationEntry[])];