refactor: complete v2 refactoring plan (Phases 1-5)
Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract findUniqueOrThrow helper (19 routers), shared Prisma select constants, useInvalidatePlanningViews hook, status badge consolidation, composite DB indexes. Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel, TimelineProjectPanel; split 28-dep useMemo into 3 focused memos. TimelineView.tsx reduced from 1,903 to 538 lines. Phase 3 — Query Performance: server-side filtering for getEntriesView, remove availability from timeline resource select, SSE event debouncing (50ms batch window). Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor components. EstimateWorkspaceClient 1,298→306 lines, EstimateWorkspaceDraftEditor 1,205→581 lines. Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573 lines), extract shared pagination helper with 11 tests. All tests pass: 209 API, 254 engine, 67 application. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
VacationStatus,
|
||||
type AllocationLike,
|
||||
type AllocationReadModel,
|
||||
type Assignment,
|
||||
type DemandRequirement,
|
||||
} from "@planarchy/shared";
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { readAppPreferences, useAppPreferences } from "~/hooks/useAppPreferences.js";
|
||||
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
|
||||
export type TimelineDisplayMode = "strip" | "bar" | "heatmap";
|
||||
import { addDays } from "./utils.js";
|
||||
import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js";
|
||||
import { DONE_STATUSES } from "./timelineConstants.js";
|
||||
|
||||
// ─── Local timeline types ─────────────────────────────────────────────────────
|
||||
// These re-declare the shapes that the original TimelineView used internally.
|
||||
// Kept here so every timeline sub-component can import them from one place.
|
||||
|
||||
export type TimelineResource = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter?: string | null;
|
||||
lcrCents: number;
|
||||
availability?: unknown;
|
||||
};
|
||||
|
||||
export type TimelineProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
status: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
orderType: string;
|
||||
budgetCents?: number;
|
||||
winProbability?: number;
|
||||
staffingReqs?: unknown;
|
||||
responsiblePerson?: string | null;
|
||||
};
|
||||
|
||||
export type TimelineRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
};
|
||||
|
||||
export type TimelineAllocation = Omit<AllocationLike, "resource" | "project" | "roleEntity" | "metadata"> & {
|
||||
resource?: TimelineResource | null;
|
||||
project: TimelineProject;
|
||||
roleEntity?: TimelineRole | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TimelineAssignmentEntry = Assignment<TimelineAllocation>;
|
||||
export type TimelineDemandEntry = DemandRequirement<TimelineAllocation>;
|
||||
|
||||
export type TimelineEntriesView = AllocationReadModel<TimelineAllocation> & {
|
||||
assignments: TimelineAssignmentEntry[];
|
||||
demands: TimelineDemandEntry[];
|
||||
};
|
||||
export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry;
|
||||
|
||||
export type ViewMode = "resource" | "project";
|
||||
|
||||
// ─── Derived resource type used throughout the timeline ─────────────────────
|
||||
export type ResourceBrief = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter: string | null | undefined;
|
||||
};
|
||||
|
||||
// ─── Project group type (project view) ──────────────────────────────────────
|
||||
export type ProjectGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
orderType: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status: string;
|
||||
resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[];
|
||||
};
|
||||
|
||||
// ─── Vacation entry type (inferred from tRPC) ──────────────────────────────
|
||||
export type VacationEntry = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
status: string;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
isHalfDay?: boolean;
|
||||
halfDayPart?: string | null;
|
||||
};
|
||||
|
||||
// ─── Context shape ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimelineContextValue {
|
||||
// ─ Data
|
||||
assignments: TimelineAssignmentEntry[];
|
||||
demands: TimelineDemandEntry[];
|
||||
visibleAssignments: TimelineAssignmentEntry[];
|
||||
visibleDemands: TimelineDemandEntry[];
|
||||
vacationsByResource: Map<string, VacationEntry[]>;
|
||||
resources: ResourceBrief[];
|
||||
resourceMap: Map<string, ResourceBrief>;
|
||||
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
|
||||
projectGroups: ProjectGroup[];
|
||||
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
|
||||
|
||||
// ─ View state
|
||||
viewStart: Date;
|
||||
viewEnd: Date;
|
||||
viewDays: number;
|
||||
setViewStart: React.Dispatch<React.SetStateAction<Date>>;
|
||||
setViewDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
filters: TimelineFilters;
|
||||
setFilters: React.Dispatch<React.SetStateAction<TimelineFilters>>;
|
||||
filterOpen: boolean;
|
||||
setFilterOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
|
||||
today: Date;
|
||||
|
||||
// ─ Display preferences
|
||||
displayMode: TimelineDisplayMode;
|
||||
heatmapScheme: HeatmapColorScheme;
|
||||
|
||||
// ─ Loading
|
||||
isLoading: boolean;
|
||||
isInitialLoading: boolean;
|
||||
totalAllocCount: number;
|
||||
activeFilterCount: number;
|
||||
|
||||
// ─ SSE is initialized by the provider (no value exposed)
|
||||
}
|
||||
|
||||
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
||||
|
||||
export function useTimelineContext(): TimelineContextValue {
|
||||
const ctx = useContext(TimelineContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useTimelineContext must be used within a <TimelineProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── Provider ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineProviderProps {
|
||||
/** Cross-project context resource IDs from drag — injected from the parent. */
|
||||
contextAllocations: TimelineAssignmentEntry[];
|
||||
isDragging: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TimelineProvider({
|
||||
contextAllocations,
|
||||
isDragging,
|
||||
children,
|
||||
}: TimelineProviderProps) {
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const [viewStart, setViewStart] = useState(() => addDays(today, -30));
|
||||
const [viewDays, setViewDays] = useState(180);
|
||||
const viewEnd = addDays(viewStart, viewDays);
|
||||
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => ({
|
||||
...DEFAULT_FILTERS,
|
||||
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
|
||||
}));
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
||||
|
||||
useTimelineSSE();
|
||||
|
||||
const { prefs: appPrefs } = useAppPreferences();
|
||||
const displayMode = appPrefs.timelineDisplayMode;
|
||||
const heatmapScheme = appPrefs.heatmapColorScheme;
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
|
||||
{ startDate: viewStart, endDate: viewEnd },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
|
||||
|
||||
const assignments = entriesView?.assignments ?? [];
|
||||
const demands = entriesView?.demands ?? [];
|
||||
|
||||
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
|
||||
{ startDate: viewStart, endDate: viewEnd, status: VacationStatus.APPROVED, limit: 500 },
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
const map = new Map<string, VacationEntry[]>();
|
||||
for (const vacation of vacationEntries as VacationEntry[]) {
|
||||
const existing = map.get(vacation.resourceId);
|
||||
if (existing) {
|
||||
existing.push(vacation);
|
||||
} else {
|
||||
map.set(vacation.resourceId, [vacation]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [vacationEntries]);
|
||||
|
||||
// When EID filter is active, explicitly fetch those resources.
|
||||
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
||||
{ eids: filters.eids, limit: 100 },
|
||||
{ enabled: filters.eids.length > 0, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
// ─── Filtered entries ──────────────────────────────────────────────────────
|
||||
|
||||
const visibleAssignments = useMemo(
|
||||
() => assignments.filter((entry) => {
|
||||
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||
return true;
|
||||
}),
|
||||
[assignments, filters.hideCompletedProjects, filters.showDrafts],
|
||||
);
|
||||
|
||||
const visibleDemands = useMemo(
|
||||
() => demands.filter((entry) => {
|
||||
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||
if (!filters.showPlaceholders) return false;
|
||||
return true;
|
||||
}),
|
||||
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
||||
);
|
||||
|
||||
const openDemandsByProject = useMemo(() => {
|
||||
const map = new Map<string, TimelineDemandEntry[]>();
|
||||
for (const demand of visibleDemands) {
|
||||
const arr = map.get(demand.projectId) ?? [];
|
||||
arr.push(demand);
|
||||
map.set(demand.projectId, arr);
|
||||
}
|
||||
return map;
|
||||
}, [visibleDemands]);
|
||||
|
||||
// ─── Resource map + allocsByResource ──────────────────────────────────────
|
||||
const { resourceMap, allocsByResource, resources } = useMemo(() => {
|
||||
const resourceMap = new Map<string, ResourceBrief>();
|
||||
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
|
||||
|
||||
if (eidFilterData?.resources) {
|
||||
for (const r of eidFilterData.resources as { id: string; displayName: string; eid: string; chapter: string | null }[]) {
|
||||
if (!resourceMap.has(r.id)) {
|
||||
resourceMap.set(r.id, { id: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of visibleAssignments) {
|
||||
if (!entry.resourceId) continue;
|
||||
if (!resourceMap.has(entry.resourceId)) {
|
||||
resourceMap.set(entry.resourceId, {
|
||||
id: entry.resource!.id,
|
||||
displayName: entry.resource!.displayName,
|
||||
eid: entry.resource!.eid,
|
||||
chapter: entry.resource!.chapter,
|
||||
});
|
||||
}
|
||||
const arr = allocsByResource.get(entry.resourceId) ?? [];
|
||||
arr.push(entry);
|
||||
allocsByResource.set(entry.resourceId, arr);
|
||||
}
|
||||
|
||||
// Merge cross-project context allocations so they appear during drag
|
||||
if (isDragging && contextAllocations.length > 0) {
|
||||
for (const ca of contextAllocations) {
|
||||
if (!ca.resourceId) continue;
|
||||
const existing = visibleAssignments.find((entry) => entry.resourceId === ca.resourceId);
|
||||
if (existing && !resourceMap.has(ca.resourceId)) {
|
||||
resourceMap.set(ca.resourceId, {
|
||||
id: existing.resource!.id,
|
||||
displayName: existing.resource!.displayName,
|
||||
eid: existing.resource!.eid,
|
||||
chapter: existing.resource!.chapter,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resources = [...resourceMap.values()].sort((a, b) =>
|
||||
a.displayName.localeCompare(b.displayName),
|
||||
);
|
||||
if (filters.chapters.length > 0) {
|
||||
resources = resources.filter((r) => r.chapter && filters.chapters.includes(r.chapter));
|
||||
}
|
||||
if (filters.eids.length > 0) {
|
||||
resources = resources.filter((r) => filters.eids.includes(r.eid));
|
||||
}
|
||||
if (filters.projectIds.length > 0) {
|
||||
resources = resources.filter((r) =>
|
||||
visibleAssignments.some(
|
||||
(e) => e.resourceId === r.id && filters.projectIds.includes(e.projectId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { resourceMap, allocsByResource, resources };
|
||||
}, [visibleAssignments, eidFilterData, isDragging, contextAllocations, filters.chapters, filters.eids, filters.projectIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Project groups (for project view) ────────────────────────────────────
|
||||
const projectGroups = useMemo(() => {
|
||||
const projectGroupMap = new Map<string, ProjectGroup>();
|
||||
const allGroupEntries: TimelineProjectEntry[] = [...visibleAssignments, ...visibleDemands];
|
||||
for (const entry of allGroupEntries) {
|
||||
let group = projectGroupMap.get(entry.projectId);
|
||||
if (!group) {
|
||||
group = {
|
||||
id: entry.projectId,
|
||||
name: entry.project.name,
|
||||
shortCode: entry.project.shortCode,
|
||||
orderType: entry.project.orderType,
|
||||
startDate: new Date(entry.project.startDate as unknown as string),
|
||||
endDate: new Date(entry.project.endDate as unknown as string),
|
||||
status: entry.project.status,
|
||||
resourceRows: [],
|
||||
};
|
||||
projectGroupMap.set(entry.projectId, group);
|
||||
}
|
||||
const currentGroup = group;
|
||||
if (entry.kind === "assignment" && entry.resourceId) {
|
||||
const existingRow = currentGroup.resourceRows.find((r) => r.resource.id === entry.resourceId);
|
||||
if (existingRow) {
|
||||
existingRow.allocs.push(entry);
|
||||
} else {
|
||||
const res = resourceMap.get(entry.resourceId);
|
||||
if (res) {
|
||||
currentGroup.resourceRows.push({ resource: res, allocs: [entry] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of projectGroupMap.values()) {
|
||||
group.resourceRows.sort((a, b) => a.resource.displayName.localeCompare(b.resource.displayName));
|
||||
}
|
||||
return [...projectGroupMap.values()]
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||
.filter((pg) => {
|
||||
if (filters.projectIds.length > 0 && !filters.projectIds.includes(pg.id)) return false;
|
||||
if (filters.chapters.length > 0 && !pg.resourceRows.some((r) => r.resource.chapter && filters.chapters.includes(r.resource.chapter))) return false;
|
||||
if (filters.eids.length > 0 && !pg.resourceRows.some((r) => filters.eids.includes(r.resource.eid))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [visibleAssignments, visibleDemands, resourceMap, filters.projectIds, filters.chapters, filters.eids]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||
const isInitialLoading = isLoading && !entriesView;
|
||||
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
||||
const activeFilterCount =
|
||||
filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||
|
||||
const value = useMemo<TimelineContextValue>(
|
||||
() => ({
|
||||
assignments,
|
||||
demands,
|
||||
visibleAssignments,
|
||||
visibleDemands,
|
||||
vacationsByResource,
|
||||
resources,
|
||||
resourceMap,
|
||||
allocsByResource,
|
||||
projectGroups,
|
||||
openDemandsByProject,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
viewDays,
|
||||
setViewStart,
|
||||
setViewDays,
|
||||
filters,
|
||||
setFilters,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
today,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
totalAllocCount,
|
||||
activeFilterCount,
|
||||
}),
|
||||
[
|
||||
assignments,
|
||||
demands,
|
||||
visibleAssignments,
|
||||
visibleDemands,
|
||||
vacationsByResource,
|
||||
resources,
|
||||
resourceMap,
|
||||
allocsByResource,
|
||||
projectGroups,
|
||||
openDemandsByProject,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
viewDays,
|
||||
filters,
|
||||
filterOpen,
|
||||
viewMode,
|
||||
today,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
totalAllocCount,
|
||||
activeFilterCount,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider value={value}>
|
||||
{children}
|
||||
</TimelineContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user