refactor(api): extract timeline holiday load support

This commit is contained in:
2026-03-31 17:40:44 +02:00
parent 7a64fe5ce5
commit a3fb95ae07
3 changed files with 291 additions and 97 deletions
@@ -0,0 +1,183 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("../lib/holiday-availability.js", () => ({
asHolidayResolverDb: vi.fn((db) => db),
getResolvedCalendarHolidays: vi.fn(),
}));
vi.mock("../router/timeline-read-shared.js", () => ({
loadTimelineEntriesReadModel: vi.fn(),
}));
import {
asHolidayResolverDb,
getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import { loadTimelineEntriesReadModel } from "../router/timeline-read-shared.js";
import {
loadTimelineHolidayOverlays,
loadTimelineHolidayOverlaysForReadModel,
} from "../router/timeline-holiday-load-support.js";
const asHolidayResolverDbMock = vi.mocked(asHolidayResolverDb);
const getResolvedCalendarHolidaysMock = vi.mocked(getResolvedCalendarHolidays);
const loadTimelineEntriesReadModelMock = vi.mocked(loadTimelineEntriesReadModel);
describe("timeline holiday load support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads the timeline read model before building holiday overlays", async () => {
const resourceFindMany = vi
.fn()
.mockResolvedValueOnce([
{
id: "resource_1",
countryId: "country_1",
federalState: "BY",
metroCityId: "city_1",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Munich" },
},
]);
const db = { resource: { findMany: resourceFindMany } } as never;
const input = {
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-07T00:00:00.000Z"),
};
loadTimelineEntriesReadModelMock.mockResolvedValueOnce({
assignments: [{ resourceId: "resource_1" }],
} as never);
getResolvedCalendarHolidaysMock.mockResolvedValueOnce([
{
date: "2026-04-03",
name: "Holiday",
scope: "COUNTRY",
calendarName: "System",
sourceType: "BUILTIN",
},
] as never);
await expect(loadTimelineHolidayOverlays(db, input)).resolves.toEqual([
expect.objectContaining({
id: "calendar-holiday:resource_1:2026-04-03",
resourceId: "resource_1",
note: "Holiday",
}),
]);
expect(loadTimelineEntriesReadModelMock).toHaveBeenCalledWith(db, input);
expect(asHolidayResolverDbMock).toHaveBeenCalledWith(db);
});
it("returns an empty list when no resource ids can be resolved", async () => {
const resourceFindMany = vi.fn();
const db = { resource: { findMany: resourceFindMany } } as never;
await expect(
loadTimelineHolidayOverlaysForReadModel(
db,
{
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-07T00:00:00.000Z"),
},
{
assignments: [{ resourceId: null }],
} as never,
),
).resolves.toEqual([]);
expect(resourceFindMany).not.toHaveBeenCalled();
expect(getResolvedCalendarHolidaysMock).not.toHaveBeenCalled();
});
it("resolves holidays for matching resources and returns overlays sorted by resource and date", async () => {
const resourceFindMany = vi
.fn()
.mockResolvedValueOnce([{ id: "resource_2" }])
.mockResolvedValueOnce([
{
id: "resource_2",
countryId: "country_2",
federalState: null,
metroCityId: null,
country: { code: "US", name: "United States" },
metroCity: null,
},
{
id: "resource_1",
countryId: "country_1",
federalState: "BY",
metroCityId: "city_1",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Munich" },
},
]);
const db = { resource: { findMany: resourceFindMany } } as never;
getResolvedCalendarHolidaysMock
.mockResolvedValueOnce([
{
date: "2026-04-04",
name: "Holiday B",
scope: "COUNTRY",
calendarName: "System",
sourceType: "BUILTIN",
},
] as never)
.mockResolvedValueOnce([
{
date: "2026-04-03",
name: "Holiday A",
scope: "CITY",
calendarName: "Munich",
sourceType: "CUSTOM",
},
] as never);
await expect(
loadTimelineHolidayOverlaysForReadModel(
db,
{
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-07T00:00:00.000Z"),
chapters: ["3D"],
},
{
assignments: [{ resourceId: "resource_1" }],
} as never,
),
).resolves.toEqual([
expect.objectContaining({
id: "calendar-holiday:resource_1:2026-04-03",
resourceId: "resource_1",
note: "Holiday A",
countryCode: "DE",
}),
expect.objectContaining({
id: "calendar-holiday:resource_2:2026-04-04",
resourceId: "resource_2",
note: "Holiday B",
countryCode: "US",
}),
]);
expect(resourceFindMany).toHaveBeenNthCalledWith(1, {
where: { chapter: { in: ["3D"] } },
select: { id: true },
});
expect(resourceFindMany).toHaveBeenNthCalledWith(2, {
where: { id: { in: ["resource_1", "resource_2"] } },
select: {
id: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
});
});
@@ -0,0 +1,100 @@
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { VacationType } from "@capakraken/db";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import {
buildTimelineHolidayResourceIds,
buildTimelineHolidayResourceWhere,
} from "./timeline-holiday-support.js";
import {
loadTimelineEntriesReadModel,
TimelineEntriesDbClient,
TimelineEntriesFilters,
} from "./timeline-read-shared.js";
export async function loadTimelineHolidayOverlays(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const readModel = await loadTimelineEntriesReadModel(db, input);
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}
export async function loadTimelineHolidayOverlaysForReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
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 [];
}
const resources = await db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
const overlays = await Promise.all(
resources.map(async (resource) => {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: input.startDate,
periodEnd: input.endDate,
countryId: resource.countryId,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name ?? null,
});
return holidays.map((holiday) => {
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
return {
id: `calendar-holiday:${resource.id}:${holiday.date}`,
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: "APPROVED" as const,
startDate: holidayDate,
endDate: holidayDate,
note: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
};
});
}),
);
return overlays.flat().sort((left, right) => {
if (left.resourceId !== right.resourceId) {
return left.resourceId.localeCompare(right.resourceId);
}
return left.startDate.getTime() - right.startDate.getTime();
});
}
@@ -1,27 +1,26 @@
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import { z } from "zod";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { controllerProcedure, protectedProcedure } from "../trpc.js";
import {
buildSelfServiceTimelineInput,
createTimelineDateRange,
createTimelineFilters,
fmtDate,
loadTimelineEntriesReadModel,
TimelineEntriesDbClient,
TimelineEntriesFilters,
TimelineWindowFiltersSchema,
} from "./timeline-read-shared.js";
import {
loadTimelineHolidayOverlays,
} from "./timeline-holiday-load-support.js";
import {
buildTimelineHolidayOverlayDetailResponse,
buildTimelineHolidayResourceIds,
buildTimelineHolidayResourceWhere,
formatTimelineHolidayOverlays,
summarizeTimelineHolidayOverlays,
type TimelineHolidayOverlayRecord,
} from "./timeline-holiday-support.js";
export {
loadTimelineHolidayOverlays,
loadTimelineHolidayOverlaysForReadModel,
} from "./timeline-holiday-load-support.js";
export function formatHolidayOverlays(
overlays: TimelineHolidayOverlayRecord[],
) {
@@ -34,94 +33,6 @@ export function summarizeHolidayOverlays(
return summarizeTimelineHolidayOverlays(overlays);
}
export async function loadTimelineHolidayOverlays(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const readModel = await loadTimelineEntriesReadModel(db, input);
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}
export async function loadTimelineHolidayOverlaysForReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
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 [];
}
const resources = await db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
const overlays = await Promise.all(
resources.map(async (resource) => {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: input.startDate,
periodEnd: input.endDate,
countryId: resource.countryId,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name ?? null,
});
return holidays.map((holiday) => {
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
return {
id: `calendar-holiday:${resource.id}:${holiday.date}`,
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: "APPROVED" as const,
startDate: holidayDate,
endDate: holidayDate,
note: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
};
});
}),
);
return overlays.flat().sort((left, right) => {
if (left.resourceId !== right.resourceId) {
return left.resourceId.localeCompare(right.resourceId);
}
return left.startDate.getTime() - right.startDate.getTime();
});
}
export const timelineHolidayReadProcedures = {
getHolidayOverlays: controllerProcedure
.input(TimelineWindowFiltersSchema)