refactor: consolidate duplicated code across web and API packages

- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx
- Centralize status badge styles and vacation color maps into status-styles.ts
- Extract dragMath.ts utility from useTimelineDrag for reuse
- Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries)
- Adopt findUniqueOrThrow() and Prisma select constants across API routers
- Add shared fmtEur() helper for API-side money formatting
- Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo
- Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal
- 38 files changed, reducing ~400 lines of duplicated code

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:10:08 +01:00
parent ddec3a927a
commit e7b74f13bd
38 changed files with 637 additions and 652 deletions
@@ -1,12 +1,11 @@
"use client";
import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
useTimelineContext,
type TimelineAssignmentEntry,
type VacationEntry,
} from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
@@ -27,6 +26,12 @@ import type {
MultiSelectState,
} from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import {
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -76,7 +81,7 @@ export interface RowMouseDownInfo {
// ─── Component ──────────────────────────────────────────────────────────────
export function TimelineResourcePanel({
function TimelineResourcePanelInner({
scrollContainerRef,
dragState,
allocDragState,
@@ -487,7 +492,7 @@ export function TimelineResourcePanel({
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForRow(
{renderVacationBlocks(
vacationBlocksByResource.get(resource.id) ?? [],
rowHeight,
)}
@@ -547,12 +552,6 @@ export function TimelineResourcePanel({
// ─── Helper types ───────────────────────────────────────────────────────────
interface VacationBlockInfo {
vacation: VacationEntry;
left: number;
width: number;
}
interface AllocBlockData {
alloc: TimelineAssignmentEntry;
lane: number;
@@ -560,81 +559,6 @@ interface AllocBlockData {
// ─── Pure render functions (no hooks, extracted from TimelineView) ───────────
const TYPE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
const TYPE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
const TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
function renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) {
if (blocks.length === 0) return null;
return blocks.map(({ vacation: v, left, width }) => {
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
const isPending = v.status === "PENDING";
return (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
isPending ? "" : borderClass,
)}
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
>
{width > 40 && (
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
</span>
)}
</div>
);
});
}
function renderRangeOverlay(
rangeState: RangeState,
resourceId: string,
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
) {
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
return null;
}
const end = rangeState.currentDate ?? rangeState.startDate;
const [selStart, selEnd] =
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
const left = toLeft(selStart);
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
return (
<div
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
style={{ left, width, top: 4, height: rowHeight - 8 }}
/>
);
}
function renderAllocBlocksFromData(
blockData: AllocBlockData[],
_allocs: TimelineAssignmentEntry[],
@@ -890,45 +814,6 @@ function renderHeatmapOverlay(
});
}
// ─── Overbooking blink overlay ───────────────────────────────────────────────
function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{
left: i * CELL_WIDTH,
width: CELL_WIDTH,
}}
/>
));
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars(
@@ -1108,5 +993,8 @@ function renderDailyBars(
});
}
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
// ─── Re-export tooltip types for the parent ─────────────────────────────────
export type { VacationBlockInfo, AllocBlockData };
export type { AllocBlockData };
export type { VacationBlockInfo } from "./renderHelpers.js";