diff --git a/packages/api/src/__tests__/timeline-holiday-read.test.ts b/packages/api/src/__tests__/timeline-holiday-read.test.ts new file mode 100644 index 0000000..e2c113e --- /dev/null +++ b/packages/api/src/__tests__/timeline-holiday-read.test.ts @@ -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, + }); + }); +}); diff --git a/packages/api/src/router/timeline-holiday-read.ts b/packages/api/src/router/timeline-holiday-read.ts index be0784d..141203b 100644 --- a/packages/api/src/router/timeline-holiday-read.ts +++ b/packages/api/src/router/timeline-holiday-read.ts @@ -13,58 +13,25 @@ import { TimelineEntriesFilters, TimelineWindowFiltersSchema, } from "./timeline-read-shared.js"; +import { + buildTimelineHolidayOverlayDetailResponse, + buildTimelineHolidayResourceIds, + buildTimelineHolidayResourceWhere, + formatTimelineHolidayOverlays, + summarizeTimelineHolidayOverlays, + type TimelineHolidayOverlayRecord, +} from "./timeline-holiday-support.js"; export function formatHolidayOverlays( - overlays: Array<{ - 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; - }>, + 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, - })); + return formatTimelineHolidayOverlays(overlays); } export function summarizeHolidayOverlays( overlays: ReturnType, ) { - const resourceIds = new Set(); - const byScope = new Map(); - - 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 })), - }; + return summarizeTimelineHolidayOverlays(overlays); } export async function loadTimelineHolidayOverlays( @@ -80,48 +47,22 @@ export async function loadTimelineHolidayOverlaysForReadModel( input: TimelineEntriesFilters, readModel: ReturnType, ) { - const resourceIds = [...new Set( - readModel.assignments - .map((assignment) => assignment.resourceId) - .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), - )]; - - if (input.resourceIds && input.resourceIds.length > 0) { - for (const resourceId of input.resourceIds) { - if (resourceId && !resourceIds.includes(resourceId)) { - resourceIds.push(resourceId); - } - } - } - - const hasResourceFilters = - (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); - } - } - } + const resourceWhere = buildTimelineHolidayResourceWhere({ + chapters: input.chapters, + eids: input.eids, + countryCodes: input.countryCodes, + }); + const matchingResources = resourceWhere + ? await db.resource.findMany({ + where: resourceWhere, + select: { id: true }, + }) + : []; + const resourceIds = buildTimelineHolidayResourceIds({ + assignmentResourceIds: readModel.assignments.map((assignment) => assignment.resourceId), + requestedResourceIds: input.resourceIds, + matchingFilterResourceIds: matchingResources.map((resource) => resource.id), + }); if (resourceIds.length === 0) { return []; @@ -221,14 +162,11 @@ export const timelineHolidayReadProcedures = { }); const formattedOverlays = formatHolidayOverlays(holidayOverlays); - return { - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - }, + return buildTimelineHolidayOverlayDetailResponse({ + startDate, + endDate, filters, - summary: summarizeHolidayOverlays(formattedOverlays), overlays: formattedOverlays, - }; + }); }), }; diff --git a/packages/api/src/router/timeline-holiday-support.ts b/packages/api/src/router/timeline-holiday-support.ts new file mode 100644 index 0000000..551572e --- /dev/null +++ b/packages/api/src/router/timeline-holiday-support.ts @@ -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, +) { + const resourceIds = new Set(); + const byScope = new Map(); + + 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; + 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; + overlays: ReturnType; +}) { + return { + period: { + startDate: fmtDate(input.startDate), + endDate: fmtDate(input.endDate), + }, + filters: input.filters, + summary: summarizeTimelineHolidayOverlays(input.overlays), + overlays: input.overlays, + }; +}