From f79967d433632e4916c13574cccc93ac142f4602 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 5 Jun 2026 12:10:30 +0200 Subject: [PATCH] feat(plan): resolveGrid for batch cell resolution --- src/__tests__/plan.test.ts | 53 ++++++++++++++++++- src/services/plan.service.ts | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index da150e0..a73853b 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import prisma from "../config/database"; -import { resolveCell } from "../services/plan.service"; +import { resolveCell, resolveGrid } from "../services/plan.service"; // --------------------------------------------------------------------------- // Test fixtures @@ -119,3 +119,54 @@ describe("plan.service.resolveCell", () => { expect(result).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// resolveGrid +// --------------------------------------------------------------------------- + +describe("plan.service.resolveGrid", () => { + it("returns a 2D map keyed by userId then date", async () => { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-01"), + date_to: new Date("2099-06-03"), + category: "work", + note: `${N}A`, + created_by: adminUserId, + }, + }); + const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05"); + expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-04"]).toBeNull(); + expect(cells[adminUserId]["2099-06-05"]).toBeNull(); + }); + + it("applies override on a covered day", async () => { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-01"), + date_to: new Date("2099-06-03"), + category: "work", + note: `${N}A`, + created_by: adminUserId, + }, + }); + await prisma.work_plan_overrides.create({ + data: { + user_id: adminUserId, + shift_date: new Date("2099-06-02"), + category: "leave", + note: `${N}B`, + created_by: adminUserId, + }, + }); + const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03"); + expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`); + expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`); + }); +}); diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index 5d5f707..f6adc95 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -96,3 +96,102 @@ export async function resolveCell( rangeTo: entry.date_to.toISOString().slice(0, 10), }; } + +/** + * Compute the effective plan cell for every (userId, date) in the + * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null. + * + * Implementation: load all entries and overrides for the range in two + * queries, then iterate the dates and resolve each cell in O(1) per + * (user, date). This avoids N+1 calls to resolveCell. + */ +export async function resolveGrid( + userIds: number[], + dateFromStr: string, + dateToStr: string, +): Promise>> { + const dateFrom = new Date(dateFromStr); + const dateTo = new Date(dateToStr); + + const [entries, overrides] = await Promise.all([ + prisma.work_plan_entries.findMany({ + where: { + user_id: { in: userIds }, + is_deleted: false, + date_from: { lte: dateTo }, + date_to: { gte: dateFrom }, + }, + orderBy: { created_at: "desc" }, + }), + prisma.work_plan_overrides.findMany({ + where: { + user_id: { in: userIds }, + is_deleted: false, + shift_date: { gte: dateFrom, lte: dateTo }, + }, + }), + ]); + + // Build a date list (inclusive) + const dates: string[] = []; + for ( + let d = new Date(dateFrom); + d <= dateTo; + d.setUTCDate(d.getUTCDate() + 1) + ) { + dates.push(d.toISOString().slice(0, 10)); + } + + const result: Record> = {}; + for (const uid of userIds) { + result[uid] = {}; + for (const dateStr of dates) { + // Override first + const override = overrides.find( + (o) => + o.user_id === uid && + o.shift_date.toISOString().slice(0, 10) === dateStr, + ); + if (override) { + result[uid][dateStr] = { + source: "override", + entryId: null, + overrideId: override.id, + user_id: override.user_id, + shift_date: dateStr, + project_id: override.project_id, + category: override.category, + note: override.note, + rangeFrom: null, + rangeTo: null, + }; + continue; + } + // First (latest by created_at) matching entry + const entry = entries.find( + (e) => + e.user_id === uid && + e.date_from <= new Date(dateStr) && + e.date_to >= new Date(dateStr), + ); + if (entry) { + result[uid][dateStr] = { + source: "entry", + entryId: entry.id, + overrideId: null, + user_id: entry.user_id, + shift_date: dateStr, + project_id: entry.project_id, + category: entry.category, + note: entry.note, + rangeFrom: entry.date_from.toISOString().slice(0, 10), + rangeTo: entry.date_to.toISOString().slice(0, 10), + }; + } else { + result[uid][dateStr] = null; + } + } + } + + return result; +}