refactor(api): extract timeline holiday support
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user