feat(plan): resolveCell/resolveGrid return arrays; dashboard shows up to 3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 10:53:26 +02:00
parent 80dc8a5c69
commit 72888bf9cd
9 changed files with 320 additions and 256 deletions

View File

@@ -108,12 +108,14 @@ export interface ResolvedCell {
}
/**
* Compute the effective plan cell for (userId, dateStr).
* Compute the effective plan records for (userId, dateStr) as an array of
* 0MAX_RECORDS_PER_CELL records.
*
* Precedence: override > entry > null.
* Soft-deleted rows are ignored (is_deleted = false filter).
* If multiple entries cover the same day, the latest by created_at wins
* and a warning is logged.
* Layering: if any override exists for the day, the overrides are returned
* (newest-first) and the range entries are hidden; otherwise the range
* entries covering the day are returned (newest-first). Soft-deleted rows
* are ignored (is_deleted = false filter). The result is capped at
* MAX_RECORDS_PER_CELL.
*
* dateStr must be a YYYY-MM-DD string. The function builds a JS Date
* from it and Prisma handles timezone conversion against the MySQL
@@ -122,31 +124,31 @@ export interface ResolvedCell {
export async function resolveCell(
userId: number,
dateStr: string,
): Promise<ResolvedCell | null> {
): Promise<ResolvedCell[]> {
const date = new Date(dateStr);
// 1. Single-day override for this exact date
const override = await prisma.work_plan_overrides.findFirst({
const overrides = await prisma.work_plan_overrides.findMany({
where: { user_id: userId, shift_date: date, is_deleted: false },
orderBy: { created_at: "desc" },
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
if (override) {
return {
source: "override",
if (overrides.length > 0) {
return overrides.map((o) => ({
source: "override" as const,
entryId: null,
overrideId: override.id,
user_id: override.user_id,
overrideId: o.id,
user_id: o.user_id,
shift_date: dateStr,
project_id: override.project_id,
...projectFields(override.projects),
category: override.category,
note: override.note,
project_id: o.project_id,
...projectFields(o.projects),
category: o.category,
note: o.note,
rangeFrom: null,
rangeTo: null,
};
}));
}
// 2. Range entry that covers the day
const entries = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
@@ -155,24 +157,11 @@ export async function resolveCell(
is_deleted: false,
},
orderBy: { created_at: "desc" },
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
if (entries.length === 0) return null;
if (entries.length > 1) {
// Multiple entries overlap. Use the latest. A persistent audit-log
// entry would be ideal here, but logAudit() currently requires a
// FastifyRequest — services have no request. Use console.warn as a
// stand-in until a service-context audit logger is introduced.
console.warn(
`[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`,
);
}
const entry = entries[0];
return {
source: "entry",
return entries.map((entry) => ({
source: "entry" as const,
entryId: entry.id,
overrideId: null,
user_id: entry.user_id,
@@ -183,12 +172,14 @@ export async function resolveCell(
note: entry.note,
rangeFrom: entry.date_from.toISOString().slice(0, 10),
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.
* Compute the effective plan records for every (userId, date) in the
* given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell[]
* (0MAX_RECORDS_PER_CELL records, same layering as resolveCell; empty
* days are []).
*
* Implementation: load all entries and overrides for the range in two
* queries, then iterate the dates and resolve each cell in O(1) per
@@ -198,7 +189,7 @@ export async function resolveGrid(
userIds: number[],
dateFromStr: string,
dateToStr: string,
): Promise<Record<number, Record<string, ResolvedCell | null>>> {
): Promise<Record<number, Record<string, ResolvedCell[]>>> {
const dateFrom = new Date(dateFromStr);
const dateTo = new Date(dateToStr);
@@ -219,11 +210,11 @@ export async function resolveGrid(
is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo },
},
orderBy: { created_at: "desc" },
include: { projects: projectSelect },
}),
]);
// Build a date list (inclusive)
const dates: string[] = [];
for (
let d = new Date(dateFrom);
@@ -233,56 +224,52 @@ export async function resolveGrid(
dates.push(d.toISOString().slice(0, 10));
}
const result: Record<number, Record<string, ResolvedCell | null>> = {};
const result: Record<number, Record<string, ResolvedCell[]>> = {};
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",
const day = new Date(dateStr);
const dayOverrides = overrides
.filter(
(o) =>
o.user_id === uid &&
o.shift_date.toISOString().slice(0, 10) === dateStr,
)
.slice(0, MAX_RECORDS_PER_CELL);
if (dayOverrides.length > 0) {
result[uid][dateStr] = dayOverrides.map((o) => ({
source: "override" as const,
entryId: null,
overrideId: override.id,
user_id: override.user_id,
overrideId: o.id,
user_id: o.user_id,
shift_date: dateStr,
project_id: override.project_id,
...projectFields(override.projects),
category: override.category,
note: override.note,
project_id: o.project_id,
...projectFields(o.projects),
category: o.category,
note: o.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,
...projectFields(entry.projects),
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;
}
const dayEntries = entries
.filter(
(e) => e.user_id === uid && e.date_from <= day && e.date_to >= day,
)
.slice(0, MAX_RECORDS_PER_CELL);
result[uid][dateStr] = dayEntries.map((e) => ({
source: "entry" as const,
entryId: e.id,
overrideId: null,
user_id: e.user_id,
shift_date: dateStr,
project_id: e.project_id,
...projectFields(e.projects),
category: e.category,
note: e.note,
rangeFrom: e.date_from.toISOString().slice(0, 10),
rangeTo: e.date_to.toISOString().slice(0, 10),
}));
}
}