refactor(api): extract timeline holiday support

This commit is contained in:
2026-03-31 15:18:43 +02:00
parent 4e9e452b94
commit a018d04251
3 changed files with 270 additions and 93 deletions
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
buildTimelineHolidayOverlayDetailResponse,
buildTimelineHolidayResourceIds,
buildTimelineHolidayResourceWhere,
formatTimelineHolidayOverlays,
summarizeTimelineHolidayOverlays,
} from "../router/timeline-holiday-support.js";
describe("timeline holiday support", () => {
it("builds resource filters only when holiday resource constraints are present", () => {
expect(buildTimelineHolidayResourceWhere({})).toBeNull();
expect(buildTimelineHolidayResourceWhere({ chapters: ["3D"] })).toEqual({
chapter: { in: ["3D"] },
});
expect(
buildTimelineHolidayResourceWhere({
chapters: ["3D"],
eids: ["E-001"],
countryCodes: ["DE"],
}),
).toEqual({
AND: [
{ chapter: { in: ["3D"] } },
{ eid: { in: ["E-001"] } },
{ country: { code: { in: ["DE"] } } },
],
});
});
it("merges assignment, requested, and matched resource ids without duplicates", () => {
expect(
buildTimelineHolidayResourceIds({
assignmentResourceIds: ["resource_1", null, "resource_2"],
requestedResourceIds: ["resource_2", "resource_3"],
matchingFilterResourceIds: ["resource_3", "resource_4"],
}),
).toEqual(["resource_1", "resource_2", "resource_3", "resource_4"]);
});
it("formats overlays and summarizes them by scope", () => {
const overlays = formatTimelineHolidayOverlays([
{
id: "overlay_1",
resourceId: "resource_1",
startDate: new Date("2026-04-03T00:00:00.000Z"),
endDate: new Date("2026-04-03T00:00:00.000Z"),
scope: "COUNTRY",
note: "Holiday",
},
{
id: "overlay_2",
resourceId: "resource_2",
startDate: new Date("2026-04-04T00:00:00.000Z"),
endDate: new Date("2026-04-04T00:00:00.000Z"),
scope: "CITY",
note: "Holiday",
},
{
id: "overlay_3",
resourceId: "resource_1",
startDate: new Date("2026-04-05T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
note: "Holiday",
},
]);
expect(overlays).toEqual([
expect.objectContaining({ startDate: "2026-04-03", endDate: "2026-04-03", scope: "COUNTRY" }),
expect.objectContaining({ startDate: "2026-04-04", endDate: "2026-04-04", scope: "CITY" }),
expect.objectContaining({ startDate: "2026-04-05", endDate: "2026-04-05", scope: null }),
]);
expect(summarizeTimelineHolidayOverlays(overlays)).toEqual({
overlayCount: 3,
holidayResourceCount: 2,
byScope: [
{ scope: "CITY", count: 1 },
{ scope: "COUNTRY", count: 1 },
{ scope: "UNKNOWN", count: 1 },
],
});
});
it("builds holiday detail responses from formatted overlays", () => {
const overlays = formatTimelineHolidayOverlays([
{
id: "overlay_1",
resourceId: "resource_1",
startDate: new Date("2026-04-03T00:00:00.000Z"),
endDate: new Date("2026-04-03T00:00:00.000Z"),
scope: "COUNTRY",
note: "Holiday",
},
]);
expect(
buildTimelineHolidayOverlayDetailResponse({
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-07T00:00:00.000Z"),
filters: { countryCodes: ["DE"] },
overlays,
}),
).toEqual({
period: {
startDate: "2026-04-01",
endDate: "2026-04-07",
},
filters: { countryCodes: ["DE"] },
summary: {
overlayCount: 1,
holidayResourceCount: 1,
byScope: [{ scope: "COUNTRY", count: 1 }],
},
overlays,
});
});
});
@@ -13,58 +13,25 @@ import {
TimelineEntriesFilters, TimelineEntriesFilters,
TimelineWindowFiltersSchema, TimelineWindowFiltersSchema,
} from "./timeline-read-shared.js"; } from "./timeline-read-shared.js";
import {
buildTimelineHolidayOverlayDetailResponse,
buildTimelineHolidayResourceIds,
buildTimelineHolidayResourceWhere,
formatTimelineHolidayOverlays,
summarizeTimelineHolidayOverlays,
type TimelineHolidayOverlayRecord,
} from "./timeline-holiday-support.js";
export function formatHolidayOverlays( export function formatHolidayOverlays(
overlays: Array<{ overlays: TimelineHolidayOverlayRecord[],
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}>,
) { ) {
return overlays.map((overlay) => ({ return formatTimelineHolidayOverlays(overlays);
id: overlay.id,
resourceId: overlay.resourceId,
startDate: fmtDate(overlay.startDate),
endDate: fmtDate(overlay.endDate),
note: overlay.note ?? null,
scope: overlay.scope ?? null,
calendarName: overlay.calendarName ?? null,
sourceType: overlay.sourceType ?? null,
countryCode: overlay.countryCode ?? null,
countryName: overlay.countryName ?? null,
federalState: overlay.federalState ?? null,
metroCityName: overlay.metroCityName ?? null,
}));
} }
export function summarizeHolidayOverlays( export function summarizeHolidayOverlays(
overlays: ReturnType<typeof formatHolidayOverlays>, overlays: ReturnType<typeof formatHolidayOverlays>,
) { ) {
const resourceIds = new Set<string>(); return summarizeTimelineHolidayOverlays(overlays);
const byScope = new Map<string, number>();
for (const overlay of overlays) {
resourceIds.add(overlay.resourceId);
const scope = overlay.scope ?? "UNKNOWN";
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
}
return {
overlayCount: overlays.length,
holidayResourceCount: resourceIds.size,
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
};
} }
export async function loadTimelineHolidayOverlays( export async function loadTimelineHolidayOverlays(
@@ -80,48 +47,22 @@ export async function loadTimelineHolidayOverlaysForReadModel(
input: TimelineEntriesFilters, input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>, readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) { ) {
const resourceIds = [...new Set( const resourceWhere = buildTimelineHolidayResourceWhere({
readModel.assignments chapters: input.chapters,
.map((assignment) => assignment.resourceId) eids: input.eids,
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), countryCodes: input.countryCodes,
)]; });
const matchingResources = resourceWhere
if (input.resourceIds && input.resourceIds.length > 0) { ? await db.resource.findMany({
for (const resourceId of input.resourceIds) { where: resourceWhere,
if (resourceId && !resourceIds.includes(resourceId)) { select: { id: true },
resourceIds.push(resourceId); })
} : [];
} const resourceIds = buildTimelineHolidayResourceIds({
} assignmentResourceIds: readModel.assignments.map((assignment) => assignment.resourceId),
requestedResourceIds: input.resourceIds,
const hasResourceFilters = matchingFilterResourceIds: matchingResources.map((resource) => resource.id),
(input.chapters?.length ?? 0) > 0 || });
(input.eids?.length ?? 0) > 0 ||
(input.countryCodes?.length ?? 0) > 0;
if (hasResourceFilters) {
const andConditions: Prisma.ResourceWhereInput[] = [];
if (input.chapters && input.chapters.length > 0) {
andConditions.push({ chapter: { in: input.chapters } });
}
if (input.eids && input.eids.length > 0) {
andConditions.push({ eid: { in: input.eids } });
}
if (input.countryCodes && input.countryCodes.length > 0) {
andConditions.push({ country: { code: { in: input.countryCodes } } });
}
const matchingResources = await db.resource.findMany({
where: andConditions.length === 1 ? andConditions[0]! : { AND: andConditions },
select: { id: true },
});
for (const resource of matchingResources) {
if (!resourceIds.includes(resource.id)) {
resourceIds.push(resource.id);
}
}
}
if (resourceIds.length === 0) { if (resourceIds.length === 0) {
return []; return [];
@@ -221,14 +162,11 @@ export const timelineHolidayReadProcedures = {
}); });
const formattedOverlays = formatHolidayOverlays(holidayOverlays); const formattedOverlays = formatHolidayOverlays(holidayOverlays);
return { return buildTimelineHolidayOverlayDetailResponse({
period: { startDate,
startDate: fmtDate(startDate), endDate,
endDate: fmtDate(endDate),
},
filters, filters,
summary: summarizeHolidayOverlays(formattedOverlays),
overlays: formattedOverlays, overlays: formattedOverlays,
}; });
}), }),
}; };
@@ -0,0 +1,122 @@
import type { Prisma } from "@capakraken/db";
import { fmtDate } from "./timeline-read-shared.js";
export interface TimelineHolidayOverlayRecord {
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}
export function formatTimelineHolidayOverlays(overlays: TimelineHolidayOverlayRecord[]) {
return overlays.map((overlay) => ({
id: overlay.id,
resourceId: overlay.resourceId,
startDate: fmtDate(overlay.startDate),
endDate: fmtDate(overlay.endDate),
note: overlay.note ?? null,
scope: overlay.scope ?? null,
calendarName: overlay.calendarName ?? null,
sourceType: overlay.sourceType ?? null,
countryCode: overlay.countryCode ?? null,
countryName: overlay.countryName ?? null,
federalState: overlay.federalState ?? null,
metroCityName: overlay.metroCityName ?? null,
}));
}
export function summarizeTimelineHolidayOverlays(
overlays: ReturnType<typeof formatTimelineHolidayOverlays>,
) {
const resourceIds = new Set<string>();
const byScope = new Map<string, number>();
for (const overlay of overlays) {
resourceIds.add(overlay.resourceId);
const scope = overlay.scope ?? "UNKNOWN";
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
}
return {
overlayCount: overlays.length,
holidayResourceCount: resourceIds.size,
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
};
}
export function buildTimelineHolidayResourceWhere(input: {
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
}): Prisma.ResourceWhereInput | null {
const andConditions: Prisma.ResourceWhereInput[] = [];
if (input.chapters && input.chapters.length > 0) {
andConditions.push({ chapter: { in: input.chapters } });
}
if (input.eids && input.eids.length > 0) {
andConditions.push({ eid: { in: input.eids } });
}
if (input.countryCodes && input.countryCodes.length > 0) {
andConditions.push({ country: { code: { in: input.countryCodes } } });
}
if (andConditions.length === 0) {
return null;
}
return andConditions.length === 1 ? andConditions[0]! : { AND: andConditions };
}
export function buildTimelineHolidayResourceIds(input: {
assignmentResourceIds: Array<string | null>;
requestedResourceIds?: string[] | undefined;
matchingFilterResourceIds?: string[] | undefined;
}) {
const resourceIds = new Set(
input.assignmentResourceIds.filter(
(resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0,
),
);
for (const resourceId of input.requestedResourceIds ?? []) {
if (resourceId) {
resourceIds.add(resourceId);
}
}
for (const resourceId of input.matchingFilterResourceIds ?? []) {
if (resourceId) {
resourceIds.add(resourceId);
}
}
return [...resourceIds];
}
export function buildTimelineHolidayOverlayDetailResponse(input: {
startDate: Date;
endDate: Date;
filters: Record<string, unknown>;
overlays: ReturnType<typeof formatTimelineHolidayOverlays>;
}) {
return {
period: {
startDate: fmtDate(input.startDate),
endDate: fmtDate(input.endDate),
},
filters: input.filters,
summary: summarizeTimelineHolidayOverlays(input.overlays),
overlays: input.overlays,
};
}