From d179cbed7a786ba510c852aed6e7cd0804003cc1 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Thu, 7 May 2026 12:57:34 +1000 Subject: [PATCH 01/11] fix(#233): stabilize planner demand and team finder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../resource-profile/NamedResourcesPanel.tsx | 149 +++-- .../resource-profile/ResourceProfileTab.tsx | 27 +- .../components/timeline/ResourceHistogram.tsx | 2 +- .../timeline/SquadPlannerDrawer.tsx | 192 +++++-- .../timeline/TimelineOptimiserDrawer.tsx | 244 ++++++--- client/src/components/timeline/timelineUx.ts | 181 ++++++ client/src/hooks/useResourceProfile.ts | 231 ++++++-- client/src/pages/TimelinePage.tsx | 513 +++++++++++------- client/src/test/ResourceProfileTab.test.tsx | 244 +++++++++ client/src/test/timelineUx.test.ts | 130 +++++ client/src/test/useResourceProfile.test.ts | 79 +++ client/src/types/backlog.ts | 28 + server/src/lib/capacityPlanExit.ts | 29 + server/src/lib/namedResourceAssignments.ts | 310 +++++++++++ server/src/lib/sa-planner.ts | 128 ++++- server/src/routes/namedResources.ts | 22 +- server/src/routes/resourceProfile.ts | 108 ++++ server/src/routes/resourceTypes.ts | 59 +- server/src/routes/squadPlan.ts | 68 ++- server/src/routes/timeline.ts | 211 ++++--- server/src/test/resourceProfile.test.ts | 100 ++++ server/src/test/resourceTypes.test.ts | 155 ++++++ server/src/test/sa-planner.test.ts | 203 +++++++ server/src/test/squadPlan.test.ts | 141 +++++ server/src/test/timeline.test.ts | 326 +++++++++++ 25 files changed, 3340 insertions(+), 540 deletions(-) create mode 100644 client/src/components/timeline/timelineUx.ts create mode 100644 client/src/test/ResourceProfileTab.test.tsx create mode 100644 client/src/test/timelineUx.test.ts create mode 100644 client/src/test/useResourceProfile.test.ts create mode 100644 server/src/lib/capacityPlanExit.ts create mode 100644 server/src/lib/namedResourceAssignments.ts create mode 100644 server/src/test/resourceTypes.test.ts diff --git a/client/src/components/resource-profile/NamedResourcesPanel.tsx b/client/src/components/resource-profile/NamedResourcesPanel.tsx index 40e89340..e3a5979a 100644 --- a/client/src/components/resource-profile/NamedResourcesPanel.tsx +++ b/client/src/components/resource-profile/NamedResourcesPanel.tsx @@ -1,5 +1,6 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../../lib/api' +import type { ResourceProfileRow } from '../../types/backlog' type PricingModel = 'ACTUAL_DAYS' | 'PRO_RATA' @@ -20,6 +21,26 @@ interface NamedResourcesPanelProps { rtId: string rtCount: number columnCount: number + allocations?: ResourceProfileRow['namedResources'] +} + +type AllocationEntry = NonNullable[number] + +function formatWeekLabel(week: number) { + return `W${week + 1}` +} + +function formatAssignedSummary(allocation?: AllocationEntry) { + const segments = allocation?.actualAllocationSegments ?? [] + if (segments.length === 0) return 'No assigned weeks' + return segments + .slice(0, 2) + .map(segment => ( + segment.startWeek === segment.endWeek + ? `${formatWeekLabel(segment.startWeek)} (${segment.days.toFixed(1)}d)` + : `${formatWeekLabel(segment.startWeek)}-${formatWeekLabel(segment.endWeek)} (${segment.days.toFixed(1)}d)` + )) + .join(', ') + (segments.length > 2 ? ` +${segments.length - 2} more` : '') } export default function NamedResourcesPanel({ @@ -27,6 +48,7 @@ export default function NamedResourcesPanel({ rtId, rtCount, columnCount, + allocations = [], }: NamedResourcesPanelProps) { const qc = useQueryClient() @@ -86,6 +108,30 @@ export default function NamedResourcesPanel({ }, }) + const allocationById = new Map(allocations.map(allocation => [allocation.id, allocation])) + const mergedResources = [ + ...resources.map(resource => ({ + ...resource, + allocation: allocationById.get(resource.id), + persisted: true, + })), + ...allocations + .filter(allocation => !resources.some(resource => resource.id === allocation.id)) + .map(allocation => ({ + id: allocation.id, + resourceTypeId: rtId, + name: allocation.name, + startWeek: allocation.startWeek, + endWeek: allocation.endWeek, + allocationPct: allocation.allocationPercent, + pricingModel: 'ACTUAL_DAYS' as PricingModel, + createdAt: '', + updatedAt: '', + allocation, + persisted: false, + })), + ] + return ( @@ -96,100 +142,123 @@ export default function NamedResourcesPanel({ {isLoading ? (

Loading…

- ) : resources.length === 0 ? ( + ) : mergedResources.length === 0 ? (

- No named resources — using aggregate count ({rtCount}) + No named resources - using aggregate count ({rtCount})

) : (
-
+
Name Start Week End Week Alloc % Pricing + Assigned weeks
- {resources.map((r) => ( + {mergedResources.map((resource) => (
{ - const val = e.target.value.trim() - if (val && val !== r.name) - updateResource.mutate({ id: r.id, name: val }) + if (!resource.persisted) return + const value = e.target.value.trim() + if (value && value !== resource.name) { + updateResource.mutate({ id: resource.id, name: value }) + } }} - className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full" + disabled={!resource.persisted} + className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60" /> { - const val = e.target.value - ? parseInt(e.target.value) + if (!resource.persisted) return + const value = e.target.value + ? parseInt(e.target.value, 10) : null - if (val !== r.startWeek) - updateResource.mutate({ id: r.id, startWeek: val }) + if (value !== resource.startWeek) { + updateResource.mutate({ id: resource.id, startWeek: value }) + } }} - className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full" + disabled={!resource.persisted} + className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60" /> { - const val = e.target.value - ? parseInt(e.target.value) + if (!resource.persisted) return + const value = e.target.value + ? parseInt(e.target.value, 10) : null - if (val !== r.endWeek) - updateResource.mutate({ id: r.id, endWeek: val }) + if (value !== resource.endWeek) { + updateResource.mutate({ id: resource.id, endWeek: value }) + } }} - className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full" + disabled={!resource.persisted} + className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60" /> { - const val = parseInt(e.target.value) + if (!resource.persisted) return + const value = parseInt(e.target.value, 10) if ( - !isNaN(val) && - val >= 0 && - val <= 100 && - val !== r.allocationPct + !isNaN(value) && + value >= 0 && + value <= 100 && + value !== resource.allocationPct ) { - updateResource.mutate({ id: r.id, allocationPct: val }) + updateResource.mutate({ id: resource.id, allocationPct: value }) } }} - className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full" + disabled={!resource.persisted} + className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60" /> +
+
{formatAssignedSummary(resource.allocation)}
+ {resource.allocation?.actualAllocatedDays ? ( +
+ {resource.allocation.actualAllocatedDays.toFixed(1)} assigned days +
+ ) : null} +
))} diff --git a/client/src/components/resource-profile/ResourceProfileTab.tsx b/client/src/components/resource-profile/ResourceProfileTab.tsx index 118fb65c..9545d76f 100644 --- a/client/src/components/resource-profile/ResourceProfileTab.tsx +++ b/client/src/components/resource-profile/ResourceProfileTab.tsx @@ -74,6 +74,23 @@ export default function ResourceProfileTab({ {row.count > 1 && (

({formatNumber(row.totalHours / row.count)}h / {formatNumber(row.totalDays / row.count)}d per person)

)} + {row.namedResources && row.namedResources.some(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0) && ( +

+ Assigned: {row.namedResources + .filter(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0) + .slice(0, 2) + .map(namedResource => { + const firstSegment = namedResource.actualAllocationSegments[0] + return firstSegment.startWeek === firstSegment.endWeek + ? `${namedResource.name} W${firstSegment.startWeek + 1}` + : `${namedResource.name} W${firstSegment.startWeek + 1}-W${firstSegment.endWeek + 1}` + }) + .join('; ')} + {row.namedResources.filter(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0).length > 2 + ? ` +${row.namedResources.filter(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0).length - 2} more` + : ''} +

+ )}
{row.count} +
+ + +
{/* ── Scrollable body ── */}
+ {seedBanner && ( +
+ {seedBanner} +
+ )} {/* Target Duration */}
@@ -553,11 +650,24 @@ export default function SquadPlannerDrawer({ projectId, open, onClose, resourceT {/* RT Constraints (min/max) */}
- +
+ + {defaultVisibility.hiddenResourceTypes.length > 0 && ( + + )} +
- {resourceTypes.map(rt => ( + {visibleResourceTypes.map(rt => (
{rt.name} @@ -600,6 +710,11 @@ export default function SquadPlannerDrawer({ projectId, open, onClose, resourceT

Left = minimum headcount · Right = maximum (blank = no limit)

+ {isFiltered && defaultVisibility.hiddenResourceTypes.length > 0 && ( +

+ Showing only demand-bearing RTs by default. Hidden until expanded: {defaultVisibility.hiddenResourceTypes.map(rt => rt.name).join(', ')}. +

+ )}
{/* Generate button */} @@ -615,6 +730,16 @@ export default function SquadPlannerDrawer({ projectId, open, onClose, resourceT {error && (
{error} +
+ If this looks like an impossible plan,{' '} + {' '} + to try the defaults. +
)} @@ -634,7 +759,7 @@ export default function SquadPlannerDrawer({ projectId, open, onClose, resourceT
📅 {result.deliveryWeeks} weeks
-
Cost
+
Planned squad cost
💰 {fmtCost(result.totalCost)}
@@ -642,6 +767,9 @@ export default function SquadPlannerDrawer({ projectId, open, onClose, resourceT
📈 {result.avgUtilisationPct.toFixed(0)}%
+

+ Headline cost reflects planned squad capacity only. Project overhead items stay outside this total. +

{/* Capacity Plan Table */} diff --git a/client/src/components/timeline/TimelineOptimiserDrawer.tsx b/client/src/components/timeline/TimelineOptimiserDrawer.tsx index f21b345b..4a8495bd 100644 --- a/client/src/components/timeline/TimelineOptimiserDrawer.tsx +++ b/client/src/components/timeline/TimelineOptimiserDrawer.tsx @@ -6,6 +6,10 @@ import { type OptimiserResponse, type OptimiserCandidate, } from '../../lib/api' +import { + getPlannerResourceTypeVisibility, + getStartingTeamFinderDefaultRange, +} from './timelineUx' // --------------------------------------------------------------------------- // Types @@ -21,7 +25,12 @@ interface Props { open: boolean onClose: () => void resourceTypes: Array<{ id: string; name: string; count: number }> + fallbackPlannedResourceTypeIds?: string[] onApplied: (snapshotId: string) => void + onRefineScenario: ( + candidate: OptimiserCandidate, + options: { allowRampUp: boolean; seedResourceTypeIds: string[] }, + ) => void } type Mode = 'speed' | 'utilisation' | 'balanced' @@ -98,6 +107,7 @@ function CandidateCard({ allowRampUp, projectId, onApplied, + onRefineScenario, }: { candidate: OptimiserCandidate rank: number @@ -107,6 +117,7 @@ function CandidateCard({ allowRampUp: boolean projectId: string onApplied: (snapshotId: string) => void + onRefineScenario: (candidate: OptimiserCandidate) => void }) { const [applyError, setApplyError] = useState(null) const [staggerEpics, setStaggerEpics] = useState(true) @@ -119,7 +130,7 @@ function CandidateCard({ onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - 'Failed to apply scenario' + 'Failed to apply starting team' setApplyError(msg) }, }) @@ -127,7 +138,7 @@ function CandidateCard({ const handleApply = () => { if ( !window.confirm( - 'Apply this scenario? The current state will be auto-snapshotted so you can roll back.', + 'Use this starting team? The current state will be auto-snapshotted so you can roll back.', ) ) return @@ -198,7 +209,7 @@ function CandidateCard({ {changedRts.length > 0 && (
- Resource changes + Starting team changes
{changedRts.map(rt => { @@ -218,7 +229,7 @@ function CandidateCard({ {rampUps.length > 0 && (
- Suggested ramp-up + Later ramp-up ideas
{rampUps.map(rt => { @@ -239,7 +250,7 @@ function CandidateCard({ )} {/* Apply section */} -
+
- +
+ + +
) @@ -277,7 +296,7 @@ function BaselineCard({ baseline, showCost }: { baseline: OptimiserCandidate; sh
- Current configuration + Current starting point
{warnCount === 0 ? ( @@ -296,7 +315,7 @@ function BaselineCard({ baseline, showCost }: { baseline: OptimiserCandidate; sh 🗓 {baseline.metrics.deliveryWeeks} weeks delivery ⚡ {baseline.metrics.avgUtilisationPct.toFixed(1)}% utilisation {showCost && baseline.metrics.estimatedCost > 0 && ( - 💰 {fmtCost(baseline.metrics.estimatedCost)} + 💰 {fmtCost(baseline.metrics.estimatedCost)} squad cost )} {totalGapWeeks > 0 && ( ⏳ {totalGapWeeks.toFixed(1)} gap wks @@ -315,7 +334,9 @@ export default function TimelineOptimiserDrawer({ open, onClose, resourceTypes, + fallbackPlannedResourceTypeIds, onApplied, + onRefineScenario, }: Props) { // ── local state ────────────────────────────────────────────────────────── const [mode, setMode] = useState('balanced') @@ -327,29 +348,36 @@ export default function TimelineOptimiserDrawer({ const [advancedOpen, setAdvancedOpen] = useState(false) const [lastResult, setLastResult] = useState(null) const [runError, setRunError] = useState(null) + const [showAllResourceTypes, setShowAllResourceTypes] = useState(false) + + const resetSettings = useCallback(() => { + const defaultVisibility = getPlannerResourceTypeVisibility( + resourceTypes, + fallbackPlannedResourceTypeIds, + false, + ) + const ranges = new Map() + + for (const rt of defaultVisibility.visibleResourceTypes) { + ranges.set(rt.id, getStartingTeamFinderDefaultRange(rt.count)) + } + + setMode('balanced') + setAllowRampUp(false) + setMaxBudget('') + setMaxDurationWeeks('') + setMinDurationWeeks('') + setAdvancedOpen(false) + setRunError(null) + setLastResult(null) + setShowAllResourceTypes(false) + setCountRanges(ranges) + }, [fallbackPlannedResourceTypeIds, resourceTypes]) // ── initialise / reset when drawer opens ───────────────────────────────── useEffect(() => { - if (open) { - setMode('balanced') - setAllowRampUp(false) - setMaxBudget('') - setMaxDurationWeeks('') - setMinDurationWeeks('') - setAdvancedOpen(false) - setRunError(null) - setLastResult(null) - - const ranges = new Map() - for (const rt of resourceTypes) { - ranges.set(rt.id, { - min: Math.max(1, rt.count - 2), - max: Math.min(6, rt.count + 2), - }) - } - setCountRanges(ranges) - } - }, [open, resourceTypes]) + if (open) resetSettings() + }, [open, resetSettings]) // ── ESC to close ────────────────────────────────────────────────────────── const handleKeyDown = useCallback( @@ -365,14 +393,46 @@ export default function TimelineOptimiserDrawer({ } }, [open, handleKeyDown]) + const defaultVisibility = getPlannerResourceTypeVisibility( + resourceTypes, + fallbackPlannedResourceTypeIds, + false, + ) + const { visibleResourceTypes, isFiltered } = getPlannerResourceTypeVisibility( + resourceTypes, + fallbackPlannedResourceTypeIds, + showAllResourceTypes, + ) + + useEffect(() => { + if (!open || visibleResourceTypes.length === 0) return + + setCountRanges(prev => { + let changed = false + const next = new Map(prev) + + for (const rt of visibleResourceTypes) { + if (!next.has(rt.id)) { + next.set(rt.id, getStartingTeamFinderDefaultRange(rt.count)) + changed = true + } + } + + return changed ? next : prev + }) + }, [open, visibleResourceTypes]) + // ── run mutation ────────────────────────────────────────────────────────── const runMutation = useMutation({ mutationFn: () => { - const countRangesArr = Array.from(countRanges.entries()).map(([resourceTypeId, r]) => ({ - resourceTypeId, - min: r.min, - max: r.max, - })) + const countRangesArr = visibleResourceTypes.map(rt => { + const range = countRanges.get(rt.id) ?? getStartingTeamFinderDefaultRange(rt.count) + return { + resourceTypeId: rt.id, + min: range.min, + max: range.max, + } + }) return runOptimiser(projectId, { mode, constraints: { @@ -391,7 +451,7 @@ export default function TimelineOptimiserDrawer({ onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - 'Failed to run optimiser' + 'Failed to find starting teams' setRunError(msg) }, }) @@ -419,10 +479,13 @@ export default function TimelineOptimiserDrawer({ // ── count range helpers ─────────────────────────────────────────────────── function setRange(rtId: string, field: 'min' | 'max', raw: string) { const v = parseInt(raw, 10) - if (isNaN(v) || v < 1) return + if (isNaN(v) || v < 0) return setCountRanges(prev => { const next = new Map(prev) - const cur = next.get(rtId) ?? { min: 1, max: 1 } + const resourceType = resourceTypes.find(rt => rt.id === rtId) + const cur = + next.get(rtId) ?? + getStartingTeamFinderDefaultRange(resourceType?.count ?? 0) const updated = { ...cur, [field]: v } if (field === 'min' && updated.min > updated.max) updated.max = updated.min if (field === 'max' && updated.max < updated.min) updated.min = updated.max @@ -446,19 +509,32 @@ export default function TimelineOptimiserDrawer({
{/* ── Header ── */}
-

🔧 Scenario Finder

- +
+

🔧 Starting Team Finder

+

+ Compare starting team options before you move into Squad Planner. +

+
+
+ + +
{/* ── Scrollable body ── */} @@ -486,12 +562,29 @@ export default function TimelineOptimiserDrawer({ {/* Per-RT count ranges */}
-
- Count ranges per resource type +
+
+ Count ranges per resource type +
+ {defaultVisibility.hiddenResourceTypes.length > 0 && ( + + )}
+

+ Defaults are intentionally broad to surface candidate squads for Squad Planner: + start at 0, then search up to the larger of current + 4 or 2× current, capped at 12. +

- {resourceTypes.map(rt => { - const range = countRanges.get(rt.id) ?? { min: 1, max: 1 } + {visibleResourceTypes.map(rt => { + const range = countRanges.get(rt.id) ?? getStartingTeamFinderDefaultRange(rt.count) return (
@@ -501,7 +594,7 @@ export default function TimelineOptimiserDrawer({ setRange(rt.id, 'min', e.target.value)} className="w-14 border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-lab3-blue" @@ -521,6 +614,16 @@ export default function TimelineOptimiserDrawer({ ) })}
+ {isFiltered && defaultVisibility.hiddenResourceTypes.length > 0 && ( +

+ Hidden zero-demand RTs are excluded from the search until you expand them: {defaultVisibility.hiddenResourceTypes.map(rt => rt.name).join(', ')}. +

+ )} + {visibleResourceTypes.length === 0 && ( +

+ No demand-bearing resource types are currently active. Expand all RTs if you still want to explore manual scenarios. +

+ )}
{/* Allow ramp-up toggle */} @@ -532,7 +635,7 @@ export default function TimelineOptimiserDrawer({ className="rounded" /> - Allow ramp-up suggestions + Include later ramp-up suggestions @@ -593,10 +696,10 @@ export default function TimelineOptimiserDrawer({ {/* Run button */} {/* Error banner */} @@ -616,7 +719,7 @@ export default function TimelineOptimiserDrawer({ {/* Search stats */} {lastResult && (
- Evaluated {lastResult.searchStats.scenariosEvaluated.toLocaleString()} scenarios in{' '} + Evaluated {lastResult.searchStats.scenariosEvaluated.toLocaleString()} team options in{' '} {(lastResult.searchStats.durationMs / 1000).toFixed(1)}s {lastResult.searchStats.sampled && ( (sampled) @@ -633,7 +736,7 @@ export default function TimelineOptimiserDrawer({ {lastResult && lastResult.candidates.length > 0 && (
- Top scenarios + Starting team options
{lastResult.candidates.map((c, i) => ( { + onRefineScenario(candidate, { + allowRampUp, + seedResourceTypeIds: visibleResourceTypes.map(rt => rt.id), + }) + onClose() + }} onApplied={(snapshotId) => { onApplied(snapshotId) onClose() @@ -658,15 +768,15 @@ export default function TimelineOptimiserDrawer({ {lastResult && lastResult.candidates.length === 0 && (

- No feasible scenarios found within these constraints. + No feasible starting teams found within these constraints.

- All evaluated scenarios had parallel-mode over-allocations. Try increasing the max + Every team option still had parallel over-allocation warnings. Try increasing the max count for under-resourced types, switching some epics to{' '} Features: sequential mode, or relaxing your duration/budget ceilings.

- {lastResult.infeasibleCount} of {lastResult.searchStats.scenariosEvaluated} scenarios were infeasible. + {lastResult.infeasibleCount} of {lastResult.searchStats.scenariosEvaluated} team options were infeasible.

)} @@ -674,8 +784,8 @@ export default function TimelineOptimiserDrawer({ {/* Empty state (no run yet) */} {!lastResult && !runError && !runMutation.isPending && (

- Set your constraints above and click Run optimiser to see ranked - scenarios. + Set your ranges above and click Find starting teams to compare ranked + options for Squad Planner.

)}
diff --git a/client/src/components/timeline/timelineUx.ts b/client/src/components/timeline/timelineUx.ts new file mode 100644 index 00000000..d5cbf119 --- /dev/null +++ b/client/src/components/timeline/timelineUx.ts @@ -0,0 +1,181 @@ +import type { OptimiserCandidateRT } from '../../lib/api' +import type { ResourceType } from '../../types/backlog' + +export interface TimelineRecommendationSignals { + hasEntries: boolean + scheduleStale: boolean + parallelWarningCount: number + demandBearingResourceTypeCount: number + scheduledFeatureCount: number + storyCount: number + hasManualOverrides: boolean +} + +export interface TimelineRecommendation { + recommendedAction: 'quick-schedule' | 'squad-planner' + badge: string + title: string + summary: string + rationale: string[] + secondarySummary: string +} + +export interface StartingTeamFinderDefaultRange { + min: number + max: number +} + +export interface SquadPlannerSeedSettings { + minFloor: Record + maxCap: Record + seededResourceTypeIds: string[] +} + +export function getTimelineRecommendation( + signals: TimelineRecommendationSignals, +): TimelineRecommendation { + const rationale: string[] = [] + let complexityScore = 0 + + if (!signals.hasEntries) { + rationale.push('No timeline exists yet, so a fast first pass is useful.') + } + + if (signals.scheduleStale) { + rationale.push('Inputs changed since the current timeline was generated.') + complexityScore += 1 + } + + if (signals.parallelWarningCount > 0) { + rationale.push( + `${signals.parallelWarningCount} parallel warning${signals.parallelWarningCount === 1 ? '' : 's'} already need team trade-offs.`, + ) + complexityScore += 2 + } + + if (signals.demandBearingResourceTypeCount >= 4) { + rationale.push( + `${signals.demandBearingResourceTypeCount} demand-bearing resource types need coordinating.`, + ) + complexityScore += 1 + } else if (signals.demandBearingResourceTypeCount > 0) { + rationale.push( + `${signals.demandBearingResourceTypeCount} demand-bearing resource type${signals.demandBearingResourceTypeCount === 1 ? '' : 's'} are active.`, + ) + } + + if (signals.scheduledFeatureCount >= 10 || signals.storyCount >= 20) { + rationale.push( + `${signals.scheduledFeatureCount} scheduled features make the plan broad enough to warrant deliberate squad shaping.`, + ) + complexityScore += 1 + } else if (signals.scheduledFeatureCount > 0) { + rationale.push(`${signals.scheduledFeatureCount} scheduled features are currently in play.`) + } + + if (signals.hasManualOverrides) { + rationale.push('Manual overrides are already in play, so the team shape should be reviewed deliberately.') + complexityScore += 1 + } + + const recommendSquadPlanner = complexityScore >= 3 + + return recommendSquadPlanner + ? { + recommendedAction: 'squad-planner', + badge: 'Recommended flow', + title: 'Use Squad Planner as the main workflow', + summary: + 'This timeline has enough moving parts that it is better to shape the team first, then apply the plan.', + rationale, + secondarySummary: + 'Quick schedule is still available for a fast baseline. Starting Team Finder can help you choose an initial squad first.', + } + : { + recommendedAction: 'quick-schedule', + badge: 'Recommended flow', + title: 'Start with Quick schedule', + summary: + 'This project looks simple enough for a quick baseline schedule before you use the heavier planning tools.', + rationale, + secondarySummary: + 'If the baseline exposes resourcing trade-offs, move into Starting Team Finder or Squad Planner next.', + } +} + +export function getPlannerResourceTypeVisibility>( + resourceTypes: T[], + effectivePlannedResourceTypeIds: string[] | undefined, + showAllResourceTypes: boolean, +) { + if ( + !effectivePlannedResourceTypeIds || + effectivePlannedResourceTypeIds.length === 0 || + showAllResourceTypes + ) { + return { + visibleResourceTypes: resourceTypes, + hiddenResourceTypes: [] as T[], + isFiltered: false, + } + } + + const plannedIds = new Set(effectivePlannedResourceTypeIds) + const visibleResourceTypes = resourceTypes.filter(rt => plannedIds.has(rt.id)) + const hiddenResourceTypes = resourceTypes.filter(rt => !plannedIds.has(rt.id)) + + return { + visibleResourceTypes, + hiddenResourceTypes, + isFiltered: hiddenResourceTypes.length > 0, + } +} + +export function getStartingTeamFinderDefaultRange(currentCount: number): StartingTeamFinderDefaultRange { + const safeCurrentCount = Number.isFinite(currentCount) ? Math.max(0, Math.floor(currentCount)) : 0 + + return { + min: 0, + max: Math.min(12, Math.max(6, safeCurrentCount + 4, Math.ceil(safeCurrentCount * 2))), + } +} + +export function getSquadPlannerSeedSettings( + candidateResourceTypes: OptimiserCandidateRT[], + options: { + allowRampUp: boolean + seedResourceTypeIds?: string[] + }, +): SquadPlannerSeedSettings { + const allowedIds = + options.seedResourceTypeIds && options.seedResourceTypeIds.length > 0 + ? new Set(options.seedResourceTypeIds) + : null + + const minFloor: Record = {} + const maxCap: Record = {} + const seededResourceTypeIds: string[] = [] + + for (const resourceType of candidateResourceTypes) { + if (allowedIds && !allowedIds.has(resourceType.resourceTypeId)) continue + + const count = Math.max(0, Math.floor(resourceType.count)) + const resourceTypeId = resourceType.resourceTypeId + + if (options.allowRampUp && resourceType.suggestedStartWeek > 0) { + minFloor[resourceTypeId] = 0 + maxCap[resourceTypeId] = count + } else { + minFloor[resourceTypeId] = count + maxCap[resourceTypeId] = count + } + + seededResourceTypeIds.push(resourceTypeId) + } + + return { + minFloor, + maxCap, + seededResourceTypeIds, + } +} diff --git a/client/src/hooks/useResourceProfile.ts b/client/src/hooks/useResourceProfile.ts index fd996a6f..c86b2d5f 100644 --- a/client/src/hooks/useResourceProfile.ts +++ b/client/src/hooks/useResourceProfile.ts @@ -26,6 +26,170 @@ export const formatNumber = (value: number, fractionDigits = 2) => export { type CommercialRow, type OverheadType } +const asciiSafe = (value: string) => value + .replace(/[–—]/g, '-') + .replace(/×/g, 'x') + +export const toCsvValue = (value: string | number | null | undefined) => { + if (value === null || value === undefined) return '' + const str = asciiSafe(typeof value === 'number' ? value.toString() : value) + if (/[",\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +function formatWeekLabel(week: number) { + return `W${week + 1}` +} + +function formatNamedResourceSegments(namedResource: NonNullable[number]) { + const segments = namedResource.actualAllocationSegments ?? [] + if (segments.length === 0) return '' + return segments + .map(segment => ( + segment.startWeek === segment.endWeek + ? `${formatWeekLabel(segment.startWeek)} (${segment.days.toFixed(2)}d)` + : `${formatWeekLabel(segment.startWeek)}-${formatWeekLabel(segment.endWeek)} (${segment.days.toFixed(2)}d)` + )) + .join('; ') +} + +function formatNamedResourceWeeks(namedResource: NonNullable[number]) { + const weeks = namedResource.actualAllocatedWeeks ?? [] + return weeks + .map(week => `${formatWeekLabel(week.week)}=${week.days.toFixed(2)}`) + .join('; ') +} + +export const buildProfileCsv = (profileData: ResourceProfile) => { + const rows: Array> = [[ + 'Section', + 'Role', + 'NamedResource', + 'SyntheticSlot', + 'Category', + 'Count', + 'HoursPerDay', + 'RoleDays', + 'PlannedDays', + 'AssignedDays', + 'DayRate', + 'Cost', + 'AvailabilityStartWeek', + 'AvailabilityEndWeek', + 'AssignedStartWeek', + 'AssignedEndWeek', + 'AssignedSpans', + 'WeekAllocations', + 'PricingModel', + ]] + + profileData.resourceRows.forEach(row => { + if (row.namedResources && row.namedResources.length > 0) { + row.namedResources.forEach(namedResource => { + rows.push([ + 'Resource', + row.name, + namedResource.name, + namedResource.synthetic ? 'Yes' : 'No', + row.category, + row.count, + row.hoursPerDay, + row.effortDays, + namedResource.allocatedDays, + namedResource.actualAllocatedDays, + row.dayRate ?? '', + row.dayRate != null ? (namedResource.actualAllocatedDays * row.dayRate).toFixed(2) : '', + namedResource.startWeek != null ? formatWeekLabel(namedResource.startWeek) : '', + namedResource.endWeek != null ? formatWeekLabel(namedResource.endWeek) : '', + namedResource.actualAllocationStartWeek != null ? formatWeekLabel(namedResource.actualAllocationStartWeek) : '', + namedResource.actualAllocationEndWeek != null ? formatWeekLabel(namedResource.actualAllocationEndWeek) : '', + formatNamedResourceSegments(namedResource), + formatNamedResourceWeeks(namedResource), + namedResource.synthetic ? 'Generated slot' : 'Configured person', + ]) + }) + return + } + + rows.push([ + 'Resource', + row.name, + '', + '', + row.category, + row.count, + row.hoursPerDay, + row.effortDays, + row.totalDays, + row.allocatedDays, + row.dayRate ?? '', + row.estimatedCost ?? '', + row.allocationStartWeek != null ? formatWeekLabel(row.allocationStartWeek) : '', + row.allocationEndWeek != null ? formatWeekLabel(row.allocationEndWeek) : '', + '', + '', + '', + '', + '', + ]) + }) + + profileData.overheadRows.forEach(row => { + const description = row.type === 'PERCENTAGE' + ? `${row.value}% of task days` + : row.type === 'DAYS_PER_WEEK' + ? `${row.value} days/week x ${profileData.projectDurationWeeks} weeks` + : `${row.value} fixed days` + rows.push([ + 'Overhead', + row.name, + '', + '', + 'Overhead', + '', + '', + row.computedDays, + row.computedDays, + row.computedDays, + row.dayRate ?? '', + row.estimatedCost ?? '', + '', + '', + '', + '', + description, + '', + '', + ]) + }) + + rows.push([ + 'Summary', + 'Total', + '', + '', + '', + '', + '', + profileData.summary.totalDays, + profileData.summary.totalDays, + profileData.summary.totalDays, + '', + profileData.summary.totalCost ?? '', + '', + '', + '', + '', + '', + '', + '', + ]) + + return rows.map(row => row.map(toCsvValue).join(',')).join('\n') +} + /** * All data-fetching, state management, mutations, and business-logic handlers * for the Resource Profile page — extracted from ResourceProfilePage.tsx. @@ -240,7 +404,7 @@ export function useResourceProfile() { mutationFn: async (rtId: string) => { const res = await api.get(`/projects/${projectId}/resource-types/${rtId}/named-resources`) const resources = res.data as NamedResourceEntry[] - if (resources.length > 1) { + if (resources.length > 0) { await api.delete(`/projects/${projectId}/resource-types/${rtId}/named-resources/${resources[resources.length - 1].id}`) } }, @@ -320,60 +484,7 @@ export function useResourceProfile() { .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'project' - const toCsvValue = (value: string | number | null | undefined) => { - if (value === null || value === undefined) return '' - const str = typeof value === 'number' ? value.toString() : value - if (/[",\n]/.test(str)) { - return `"${str.replace(/"/g, '""')}"` - } - return str - } - - const buildProfileCsv = (profileData: ResourceProfile) => { - const rows: Array> = [[ - 'Role', 'Category', 'Count', 'HoursPerDay', 'Hours', 'Days', 'DayRate', 'Cost', - ]] - profileData.resourceRows.forEach(row => { - rows.push([ - row.name, - row.category, - row.count, - row.hoursPerDay, - row.totalHours, - row.totalDays, - row.dayRate ?? '', - row.estimatedCost ?? '', - ]) - }) - profileData.overheadRows.forEach(row => { - const description = row.type === 'PERCENTAGE' - ? `${row.value}% of task days` - : row.type === 'DAYS_PER_WEEK' - ? `${row.value} days/week × ${profileData.projectDurationWeeks} weeks` - : `${row.value} fixed days` - rows.push([ - row.name, - 'Overhead', - '', - `— ${description}`, - '', - row.computedDays, - row.dayRate ?? '', - row.estimatedCost ?? '', - ]) - }) - rows.push([ - 'Total', - '', - '', - '', - profileData.summary.totalHours, - profileData.summary.totalDays, - '', - profileData.summary.totalCost ?? '', - ]) - return rows.map(r => r.map(toCsvValue).join(',')).join('\n') - } + const createCsvBlob = (csv: string) => new Blob([csv], { type: 'text/csv;charset=utf-8' }) const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob) @@ -387,7 +498,7 @@ export function useResourceProfile() { const handleExportProfile = () => { if (!profile) return const csv = buildProfileCsv(profile) - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const blob = createCsvBlob(csv) const safeName = slugify(project?.name ?? 'project') downloadBlob(blob, `${safeName}-resource-profile.csv`) } @@ -399,8 +510,12 @@ export function useResourceProfile() { const safeName = slugify(project?.name ?? 'project') const zip = new JSZip() zip.file(`${safeName}-resource-profile.csv`, csv) - const backlogRes = await api.get(`/projects/${projectId}/backlog/export-csv`, { responseType: 'blob' }) + const [backlogRes, timelineRes] = await Promise.all([ + api.get(`/projects/${projectId}/backlog/export-csv`, { responseType: 'blob' }), + api.get(`/projects/${projectId}/timeline/export/csv`, { responseType: 'blob' }), + ]) zip.file(`${safeName}-backlog.csv`, backlogRes.data) + zip.file(`${safeName}-timeline.csv`, timelineRes.data) const blob = await zip.generateAsync({ type: 'blob' }) downloadBlob(blob, `${safeName}-project-export.zip`) } catch (err) { @@ -540,7 +655,7 @@ export function useResourceProfile() { createOverhead, updateOverhead, deleteOverhead, handleFormSubmit, handleEdit, handleDelete, resetForm, - slugify, toCsvValue, buildProfileCsv, downloadBlob, + slugify, toCsvValue, buildProfileCsv, createCsvBlob, downloadBlob, handleExportProfile, handleExportFull, handleDiscountSubmit, handleApplyRateCard, startEditAllocation, getAllocationBadge, diff --git a/client/src/pages/TimelinePage.tsx b/client/src/pages/TimelinePage.tsx index f24c40e6..eb948a38 100644 --- a/client/src/pages/TimelinePage.tsx +++ b/client/src/pages/TimelinePage.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toPng } from 'html-to-image' import { api } from '../lib/api' +import type { OptimiserCandidate } from '../lib/api' import { useIsDark } from '../hooks/useIsDark' import AppLayout from '../components/layout/AppLayout' import type { Project, ResourceType, TimelineSummary, TimelineEntry, NamedResourceEntry } from '../types/backlog' @@ -14,6 +15,11 @@ import type { GanttScale } from '../hooks/useGanttLayout' import { colWForScale, LABEL_W } from '../hooks/useGanttLayout' import TimelineOptimiserDrawer from '../components/timeline/TimelineOptimiserDrawer' import SquadPlannerDrawer from '../components/timeline/SquadPlannerDrawer' +import { + getTimelineRecommendation, + getSquadPlannerSeedSettings, + type SquadPlannerSeedSettings, +} from '../components/timeline/timelineUx' import SnapshotHistoryPanel from '../components/SnapshotHistoryPanel' const CATEGORY_HEADER_BG: Record = { @@ -41,6 +47,23 @@ const RESOURCE_COLOURS = [ 'bg-rose-200', 'bg-violet-200', 'bg-teal-200', 'bg-orange-200', ] +function formatWeekLabel(week: number) { + return `W${week + 1}` +} + +function formatAllocationSummary(resource: NamedResourceEntry) { + const segments = resource.actualAllocationSegments ?? [] + if (segments.length === 0) return 'No assigned weeks' + return segments + .slice(0, 2) + .map(segment => ( + segment.startWeek === segment.endWeek + ? `${formatWeekLabel(segment.startWeek)} (${segment.days.toFixed(1)}d)` + : `${formatWeekLabel(segment.startWeek)}-${formatWeekLabel(segment.endWeek)} (${segment.days.toFixed(1)}d)` + )) + .join(', ') + (segments.length > 2 ? ` +${segments.length - 2} more` : '') +} + function NamedResourcesPanel({ namedResources, totalWeeks, @@ -115,7 +138,7 @@ function NamedResourcesPanel({ > {nr.name} - {modeLabel} + {formatAllocationSummary(nr)} · {modeLabel}
) @@ -159,64 +182,9 @@ function NamedResourcesPanel({ const start = nr.startWeek ?? 0 const end = nr.endWeek ?? projectEndWeek const colour = RESOURCE_COLOURS[(colourIdx++) % RESOURCE_COLOURS.length] - const isEffort = (nr.allocationMode ?? 'EFFORT') === 'EFFORT' + const actualAllocatedWeeks = nr.actualAllocatedWeeks ?? [] + const actualAllocationSegments = nr.actualAllocationSegments ?? [] const rtDemand = demandByRt.get(rtName) - - if (isEffort && rtDemand) { - // T&M: render a demand-following mini histogram per person. - // weeklyDemand tracks the whole resource type pool, so divide by - // the number of named resources to get each person's share. - const personCount = Math.max(people.length, 1) - const ROW_H = 28 - const maxCap = Math.max(...Array.from(rtDemand.values()).map(d => d.capacity / personCount), 1) - return ( -
- - {Array.from({ length: totalWeeks }, (_, w) => { - const d = rtDemand.get(w) - if (!d || d.demand <= 0) return null - const personDemand = d.demand / personCount - const personCap = d.capacity / personCount - const pct = Math.min(personDemand / maxCap, 1) - const barH = Math.max(Math.round(pct * ROW_H), 2) - return ( - - setTooltip({ - x: e.clientX, - y: e.clientY, - content: `${nr.name} · T&M\nWk ${w}: ${personDemand.toFixed(1)} / ${personCap.toFixed(1)} days (${Math.round(personDemand / personCap * 100)}%)`, - })} - onMouseMove={(e) => setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : prev)} - onMouseLeave={() => setTooltip(null)} - style={{ cursor: 'default' }} - /> - - ) - })} - -
- ) - } - - // Fixed allocation (FULL_PROJECT, TIMELINE or CAPACITY_PLAN): flat bar - const barLeft = (start + weekOffset) * colW - const barWidth = Math.max((end - start + 1) * colW - 4, 8) return (
setTooltip({ - x: e.clientX, - y: e.clientY, - content: `${nr.name} · W${Math.floor(start + weekOffset) + 1}–W${Math.floor(end + weekOffset) + 1} · ${nr.allocationPct}%`, - })} - onMouseMove={(e) => setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : prev)} - onMouseLeave={() => setTooltip(null)} + className="absolute top-1 h-[28px] rounded border border-dashed border-gray-300/80 dark:border-gray-600/80 bg-transparent" + style={{ + left: (start + weekOffset) * colW + 2, + width: Math.max((end - start + 1) * colW - 4, 8), + }} + /> + {actualAllocationSegments.map((segment, segmentIndex) => ( +
setTooltip({ + x: e.clientX, + y: e.clientY, + content: `${nr.name} · ${formatWeekLabel(segment.startWeek)}-${formatWeekLabel(segment.endWeek)} · ${segment.days.toFixed(1)} assigned days`, + })} + onMouseMove={(e) => setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : prev)} + onMouseLeave={() => setTooltip(null)} + > + {segment.days.toFixed(1)}d +
+ ))} + - {nr.name} — {nr.allocationPct}% -
+ {actualAllocatedWeeks.map((allocation) => { + const demand = rtDemand?.get(allocation.week) + const fillPct = allocation.capacityDays > 0 + ? Math.min(allocation.days / allocation.capacityDays, 1) + : 0 + const barH = Math.max(Math.round(fillPct * 18), 4) + return ( + setTooltip({ + x: e.clientX, + y: e.clientY, + content: `${nr.name} · ${formatWeekLabel(allocation.week)}: ${allocation.days.toFixed(1)} / ${allocation.capacityDays.toFixed(1)} assigned days${demand ? ` · role demand ${demand.demand.toFixed(1)} / ${demand.capacity.toFixed(1)}` : ''}`, + })} + onMouseMove={(e) => setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : prev)} + onMouseLeave={() => setTooltip(null)} + style={{ cursor: 'default' }} + /> + ) + })} +
) })} @@ -271,6 +285,7 @@ export default function TimelinePage() { const [resourceLevel, setResourceLevel] = useState(() => localStorage.getItem(rlKey) === 'true') const [optimiserOpen, setOptimiserOpen] = useState(false) const [squadPlannerOpen, setSquadPlannerOpen] = useState(false) + const [squadPlannerSeedSettings, setSquadPlannerSeedSettings] = useState(null) const [showHistory, setShowHistory] = useState(false) const SCALE_KEY = 'gantt-scale' @@ -440,9 +455,15 @@ export default function TimelinePage() { qc.invalidateQueries({ queryKey: ['timeline', projectId] }) qc.invalidateQueries({ queryKey: ['snapshots', projectId] }) setOptimiserOpen(false) - alert(`Scenario applied (snapshot ${snapshotId}). Roll back via the History panel if needed.`) + alert(`Starting team applied (snapshot ${snapshotId}). Roll back via the History panel if needed.`) } + const openSquadPlanner = useCallback(() => { + setSquadPlannerSeedSettings(null) + setOptimiserOpen(false) + setSquadPlannerOpen(true) + }, []) + // Derived list of resource types for the optimiser (id, name, count) const resourceTypesForOptimiser = (resourceTypes ?? []).map(rt => ({ id: rt.id, @@ -736,6 +757,56 @@ export default function TimelinePage() { return Array.from(map.entries()) }, [resourceTypes, timeline]) + const hasTimelineEntries = (timeline?.entries?.length ?? 0) > 0 + const hasManualOverrides = !!timeline?.entries?.some(e => e.isManual) + const demandBearingResourceTypeIds = useMemo(() => { + if (!resourceTypes || resourceTypes.length === 0) return [] + + const rtNamesWithDemand = new Set() + for (const row of timeline?.weeklyDemand ?? []) { + if (row.demandDays > 0) rtNamesWithDemand.add(row.resourceTypeName) + } + if (rtNamesWithDemand.size === 0) return [] + + return resourceTypes.filter(rt => rtNamesWithDemand.has(rt.name)).map(rt => rt.id) + }, [resourceTypes, timeline?.weeklyDemand]) + const demandBearingResourceTypeCount = useMemo(() => { + const rtNamesWithDemand = new Set() + for (const row of timeline?.weeklyDemand ?? []) { + if (row.demandDays > 0) rtNamesWithDemand.add(row.resourceTypeName) + } + return rtNamesWithDemand.size + }, [timeline?.weeklyDemand]) + const quickScheduleLabel = hasTimelineEntries ? 'Quick schedule again' : 'Quick schedule' + const quickScheduleDescription = hasTimelineEntries + ? 'Refresh the current timeline from the latest backlog and resourcing inputs.' + : 'Generate a fast baseline timeline from the current backlog.' + const handleOptimiserRefine = useCallback( + ( + candidate: OptimiserCandidate, + options: { allowRampUp: boolean; seedResourceTypeIds: string[] }, + ) => { + setSquadPlannerSeedSettings( + getSquadPlannerSeedSettings(candidate.resourceTypes, { + allowRampUp: options.allowRampUp, + seedResourceTypeIds: options.seedResourceTypeIds, + }), + ) + setOptimiserOpen(false) + setSquadPlannerOpen(true) + }, + [], + ) + const timelineRecommendation = getTimelineRecommendation({ + hasEntries: hasTimelineEntries, + scheduleStale, + parallelWarningCount: timeline?.parallelWarnings?.length ?? 0, + demandBearingResourceTypeCount, + scheduledFeatureCount: timeline?.entries?.length ?? 0, + storyCount: timeline?.storyEntries?.length ?? 0, + hasManualOverrides, + }) + // Map named resources from timeline by resourceTypeId (for the Resource Counts panel) const rtNRMap = useMemo(() => { const map = new Map() @@ -767,128 +838,176 @@ export default function TimelinePage() { {/* Setup bar */}
-
-
- - setStartDateInput(e.target.value)} - onBlur={handleStartDateBlur} - className="border border-gray-200 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue" - /> +
+
+
+ + setStartDateInput(e.target.value)} + onBlur={handleStartDateBlur} + className="border border-gray-200 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue" + /> +
+ + {timeline?.projectedEndDate && ( +
+ Projected end:{' '} + {new Date(timeline.projectedEndDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + {(project?.bufferWeeks ?? 0) > 0 && ( + + +{project!.bufferWeeks}w buffer + + )} +
+ )} + {timeline?.startDate ? ( + + Current plan starts: {formatDate(timeline.startDate)} + + ) : ( + No timeline generated yet + )}
-
- - - - - {timeline?.entries && timeline.entries.length > 0 && ( - - )} - {timeline?.entries?.some(e => e.isManual) && ( + +
+
+
+
+ {timelineRecommendation.badge} +
+
+

{timelineRecommendation.title}

+

{timelineRecommendation.summary}

+
+
    + {(timelineRecommendation.rationale.length > 0 + ? timelineRecommendation.rationale + : ['Use the action below as your starting point, then move into the other tools if you need more control.'] + ).map(reason => ( +
  • + • {reason} +
  • + ))} +
+

{timelineRecommendation.secondarySummary}

+
+ +
+ {timelineRecommendation.recommendedAction === 'quick-schedule' ? ( + + ) : ( + + )} +

+ {timelineRecommendation.recommendedAction === 'quick-schedule' + ? quickScheduleDescription + : 'Shape the demand-bearing team first, then apply the plan back to the timeline.'} +

+
+ {timelineRecommendation.recommendedAction === 'quick-schedule' ? ( + + ) : ( + + )} + + + {hasManualOverrides && ( + + )} +
+
+
+
+ +
+ {timeline?.entries && timeline.entries.length > 0 && ( + <> + + + + )} - )} -
- {/* Export buttons */} - {timeline?.entries && timeline.entries.length > 0 && ( - <> - - -
- - )} - - - {timeline?.projectedEndDate && ( -
- Projected end:{' '} - {new Date(timeline.projectedEndDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} - {(project?.bufferWeeks ?? 0) > 0 && ( - - +{project!.bufferWeeks}w buffer - - )} -
- )} - {timeline?.startDate && ( - - Last scheduled: {formatDate(timeline.startDate)} - - )} - {!timeline?.startDate && ( - Not yet scheduled - )} +
{/* Stale schedule banner */} {scheduleStale && (
- ⚠ Schedule may be stale (dependencies, epic mode, or resourcing changed) — re-run Auto-schedule to apply. + + ⚠ Timeline inputs changed (dependencies, sequencing, or resourcing). Refresh with Quick schedule for a fast baseline, or reopen Squad Planner if the team shape now needs rework. +
)} @@ -896,10 +1015,13 @@ export default function TimelinePage() { {/* Parallel over-allocation warnings */} {(timeline?.parallelWarnings ?? []).length > 0 && (
-

⚠ Resource over-allocation in parallel epics

+

⚠ Parallel demand is outrunning the current team

+

+ Use Starting Team Finder to choose a better opening squad, or go straight to Squad Planner to reshape demand-bearing roles. +

{(timeline!.parallelWarnings!).map((w, i) => (

- {w.epicName} — {w.resourceTypeName}: {w.demandDays.toFixed(1)} person-days needed, only {w.capacityDays.toFixed(1)} days available at current headcount. Increase count or switch to "Features: sequential" mode. + {w.epicName} — {w.resourceTypeName}: {w.demandDays.toFixed(1)} person-days needed, only {w.capacityDays.toFixed(1)} days available at current headcount. Increase count, reduce overlap, or switch to "Features: sequential" mode.

))}
@@ -911,7 +1033,7 @@ export default function TimelinePage() { onClick={() => setResourcesOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" > - Resource Counts — adjust before scheduling + Resource Counts — adjust before Quick schedule or Squad Planner {resourcesOpen ? '▲' : '▼'} {resourcesOpen && ( @@ -1083,7 +1205,7 @@ export default function TimelinePage() { {!isLoading && (!timeline?.entries || timeline.entries.length === 0) && (
- Set a start date and click Auto-schedule to generate your timeline + Set a start date, then use Quick schedule for a fast baseline or open Squad Planner for a more deliberate team plan.
)} @@ -1242,12 +1364,12 @@ export default function TimelinePage() { Cancel {entry.isManual && ( - )} @@ -1344,13 +1466,20 @@ export default function TimelinePage() { open={optimiserOpen} onClose={() => setOptimiserOpen(false)} resourceTypes={resourceTypesForOptimiser} + fallbackPlannedResourceTypeIds={demandBearingResourceTypeIds} onApplied={handleOptimiserApplied} + onRefineScenario={handleOptimiserRefine} /> setSquadPlannerOpen(false)} + onClose={() => { + setSquadPlannerOpen(false) + setSquadPlannerSeedSettings(null) + }} resourceTypes={resourceTypesForOptimiser} + fallbackPlannedResourceTypeIds={demandBearingResourceTypeIds} + seedSettings={squadPlannerSeedSettings} /> ) diff --git a/client/src/test/ResourceProfileTab.test.tsx b/client/src/test/ResourceProfileTab.test.tsx new file mode 100644 index 00000000..13570e35 --- /dev/null +++ b/client/src/test/ResourceProfileTab.test.tsx @@ -0,0 +1,244 @@ +import React, { type ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import ResourceProfileTab from '@/components/resource-profile/ResourceProfileTab' + +function createProps( + count: number, + overrides: Partial> = {}, +): ComponentProps { + const removeMutate = vi.fn() + const addMutate = vi.fn() + const updateResourceTypeMutate = vi.fn() + + return { + projectId: 'project-1', + profile: { + projectId: 'project-1', + hoursPerDay: 8, + projectDurationWeeks: 0, + bufferWeeks: 0, + onboardingWeeks: 0, + resourceRows: [{ + resourceTypeId: 'rt-security', + name: 'Security Consultant', + category: 'GOVERNANCE', + count, + hoursPerDay: 8, + dayRate: 1200, + totalHours: 0, + totalDays: 0, + effortDays: 0, + allocatedDays: 0, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + derivedStartWeek: null, + derivedEndWeek: null, + estimatedCost: null, + epics: [], + namedResources: [], + }], + overheadRows: [], + summary: { + totalHours: 0, + totalDays: 0, + totalCost: null, + hasCost: false, + }, + }, + profileLoading: false, + overheadItems: [], + resourceTypes: [], + filteredResourceRows: [{ + resourceTypeId: 'rt-security', + name: 'Security Consultant', + category: 'GOVERNANCE', + count, + hoursPerDay: 8, + dayRate: 1200, + totalHours: 0, + totalDays: 0, + effortDays: 0, + allocatedDays: 0, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + derivedStartWeek: null, + derivedEndWeek: null, + estimatedCost: null, + epics: [], + namedResources: [], + }], + hasCost: false, + columnCount: 8, + chartData: [], + expandedRows: new Set(), + expandedNamedResources: new Set(), + editingId: null, + form: { + name: '', + resourceTypeId: '', + type: 'PERCENTAGE', + value: '', + }, + setForm: vi.fn(), + formError: null, + bufferWeeks: 0, + setBufferWeeks: vi.fn(), + onboardingWeeks: 0, + setOnboardingWeeks: vi.fn(), + toggleRow: vi.fn(), + toggleNamedResources: vi.fn(), + resetForm: vi.fn(), + handleFormSubmit: vi.fn(), + handleEdit: vi.fn(), + handleDelete: vi.fn(), + updateResourceType: { mutate: updateResourceTypeMutate } as never, + addPerson: { mutate: addMutate } as never, + removeLastPerson: { mutate: removeMutate } as never, + createOverhead: { isPending: false } as never, + updateOverhead: { isPending: false } as never, + weekToDate: vi.fn(() => null), + fmtDate: vi.fn(() => ''), + formatNumber: (value: number, fractionDigits = 2) => value.toFixed(fractionDigits), + saveBufferOnboarding: vi.fn(), + ...overrides, + } +} + +describe('ResourceProfileTab', () => { + it('allows removing the final named resource when count is 1', () => { + const removeMutate = vi.fn() + + render() + + const removeButton = screen.getByTitle('Remove person') + expect(removeButton).toBeEnabled() + + fireEvent.click(removeButton) + + expect(removeMutate).toHaveBeenCalledWith('rt-security') + }) + + it('disables removal only when the count is already zero', () => { + render() + + expect(screen.getByTitle('Remove person')).toBeDisabled() + }) + + it('shows named resource assignment summaries on the role row', () => { + render( + , + ) + + expect(screen.getByText('Assigned: Alex W3-W4')).toBeInTheDocument() + }) +}) diff --git a/client/src/test/timelineUx.test.ts b/client/src/test/timelineUx.test.ts new file mode 100644 index 00000000..758d97a6 --- /dev/null +++ b/client/src/test/timelineUx.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { + getPlannerResourceTypeVisibility, + getSquadPlannerSeedSettings, + getStartingTeamFinderDefaultRange, + getTimelineRecommendation, +} from '@/components/timeline/timelineUx' + +describe('getTimelineRecommendation', () => { + it('recommends quick schedule for a simple first-pass project', () => { + const recommendation = getTimelineRecommendation({ + hasEntries: false, + scheduleStale: false, + parallelWarningCount: 0, + demandBearingResourceTypeCount: 2, + scheduledFeatureCount: 0, + storyCount: 0, + hasManualOverrides: false, + }) + + expect(recommendation.recommendedAction).toBe('quick-schedule') + expect(recommendation.title).toContain('Quick schedule') + }) + + it('recommends squad planner when warnings and complexity stack up', () => { + const recommendation = getTimelineRecommendation({ + hasEntries: true, + scheduleStale: true, + parallelWarningCount: 2, + demandBearingResourceTypeCount: 5, + scheduledFeatureCount: 14, + storyCount: 28, + hasManualOverrides: true, + }) + + expect(recommendation.recommendedAction).toBe('squad-planner') + expect(recommendation.rationale).toEqual( + expect.arrayContaining([ + expect.stringContaining('parallel warnings'), + expect.stringContaining('demand-bearing resource types'), + ]), + ) + }) +}) + +describe('getPlannerResourceTypeVisibility', () => { + const resourceTypes = [ + { id: 'rt-dev', name: 'Developer' }, + { id: 'rt-ba', name: 'Business Analyst' }, + { id: 'rt-pm', name: 'Project Manager' }, + ] + + it('hides zero-demand resource types by default once the plan identifies active roles', () => { + const visibility = getPlannerResourceTypeVisibility(resourceTypes, ['rt-dev', 'rt-pm'], false) + + expect(visibility.visibleResourceTypes.map(rt => rt.id)).toEqual(['rt-dev', 'rt-pm']) + expect(visibility.hiddenResourceTypes.map(rt => rt.id)).toEqual(['rt-ba']) + expect(visibility.isFiltered).toBe(true) + }) + + it('shows all resource types when the user expands the full list', () => { + const visibility = getPlannerResourceTypeVisibility(resourceTypes, ['rt-dev'], true) + + expect(visibility.visibleResourceTypes.map(rt => rt.id)).toEqual(['rt-dev', 'rt-ba', 'rt-pm']) + expect(visibility.hiddenResourceTypes).toEqual([]) + }) + + it('shows all resource types when no planned ids are available yet', () => { + const visibility = getPlannerResourceTypeVisibility(resourceTypes, undefined, false) + + expect(visibility.visibleResourceTypes.map(rt => rt.id)).toEqual(['rt-dev', 'rt-ba', 'rt-pm']) + expect(visibility.hiddenResourceTypes).toEqual([]) + expect(visibility.isFiltered).toBe(false) + }) +}) + +describe('getStartingTeamFinderDefaultRange', () => { + it('starts at zero and searches broadly around the current count', () => { + expect(getStartingTeamFinderDefaultRange(1)).toEqual({ min: 0, max: 6 }) + expect(getStartingTeamFinderDefaultRange(3)).toEqual({ min: 0, max: 7 }) + expect(getStartingTeamFinderDefaultRange(5)).toEqual({ min: 0, max: 10 }) + }) + + it('caps the search range at twelve people', () => { + expect(getStartingTeamFinderDefaultRange(8)).toEqual({ min: 0, max: 12 }) + expect(getStartingTeamFinderDefaultRange(20)).toEqual({ min: 0, max: 12 }) + }) +}) + +describe('getSquadPlannerSeedSettings', () => { + const candidateResourceTypes = [ + { resourceTypeId: 'rt-dev', count: 4, suggestedStartWeek: 0 }, + { resourceTypeId: 'rt-ba', count: 2, suggestedStartWeek: 3 }, + { resourceTypeId: 'rt-pm', count: 1, suggestedStartWeek: 0 }, + ] + + it('locks seeded counts unless ramp-up is explicitly allowed later', () => { + const seed = getSquadPlannerSeedSettings(candidateResourceTypes, { + allowRampUp: false, + seedResourceTypeIds: ['rt-dev', 'rt-ba'], + }) + + expect(seed.minFloor).toEqual({ + 'rt-dev': 4, + 'rt-ba': 2, + }) + expect(seed.maxCap).toEqual({ + 'rt-dev': 4, + 'rt-ba': 2, + }) + expect(seed.seededResourceTypeIds).toEqual(['rt-dev', 'rt-ba']) + }) + + it('uses zero floors for later ramp-up candidates and leaves unseeded RTs alone', () => { + const seed = getSquadPlannerSeedSettings(candidateResourceTypes, { + allowRampUp: true, + seedResourceTypeIds: ['rt-ba', 'rt-pm'], + }) + + expect(seed.minFloor).toEqual({ + 'rt-ba': 0, + 'rt-pm': 1, + }) + expect(seed.maxCap).toEqual({ + 'rt-ba': 2, + 'rt-pm': 1, + }) + expect(seed.seededResourceTypeIds).toEqual(['rt-ba', 'rt-pm']) + }) +}) diff --git a/client/src/test/useResourceProfile.test.ts b/client/src/test/useResourceProfile.test.ts new file mode 100644 index 00000000..ceee8ec0 --- /dev/null +++ b/client/src/test/useResourceProfile.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { buildProfileCsv } from '@/hooks/useResourceProfile' +import type { ResourceProfile } from '@/types/backlog' + +describe('buildProfileCsv', () => { + it('exports named-resource rows with week allocations using ASCII-safe labels', () => { + const profile: ResourceProfile = { + projectId: 'project-1', + hoursPerDay: 8, + projectDurationWeeks: 12, + bufferWeeks: 0, + onboardingWeeks: 0, + resourceRows: [ + { + resourceTypeId: 'rt-security', + name: 'Security Consultant', + category: 'GOVERNANCE', + count: 1, + hoursPerDay: 8, + dayRate: 1200, + totalHours: 40, + totalDays: 10, + effortDays: 10, + allocatedDays: 10, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + derivedStartWeek: 2, + derivedEndWeek: 3, + estimatedCost: 12000, + epics: [], + namedResources: [ + { + id: 'nr-1', + name: 'Alex — Security', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + allocatedDays: 10, + derivedStartWeek: 2, + derivedEndWeek: 3, + actualAllocatedDays: 2.5, + actualAllocationStartWeek: 2, + actualAllocationEndWeek: 3, + actualAllocatedWeeks: [ + { week: 2, days: 1.5, capacityDays: 5 }, + { week: 3, days: 1, capacityDays: 5 }, + ], + actualAllocationSegments: [ + { startWeek: 2, endWeek: 3, days: 2.5 }, + ], + synthetic: false, + }, + ], + }, + ], + overheadRows: [], + summary: { + totalHours: 40, + totalDays: 10, + totalCost: 12000, + hasCost: true, + }, + } + + const csv = buildProfileCsv(profile) + + expect(csv).toContain('Section,Role,NamedResource') + expect(csv).toContain('Resource,Security Consultant,Alex - Security,No') + expect(csv).toContain('W3-W4 (2.50d)') + expect(csv).toContain('W3=1.50; W4=1.00') + expect(csv).not.toContain('—') + expect(csv).not.toContain('×') + }) +}) diff --git a/client/src/types/backlog.ts b/client/src/types/backlog.ts index 612d7567..ca31209d 100644 --- a/client/src/types/backlog.ts +++ b/client/src/types/backlog.ts @@ -183,6 +183,20 @@ export interface NamedResourceEntry { allocationPercent?: number allocationStartWeek?: number | null allocationEndWeek?: number | null + actualAllocatedDays?: number + actualAllocationStartWeek?: number | null + actualAllocationEndWeek?: number | null + actualAllocatedWeeks?: Array<{ + week: number + days: number + capacityDays: number + }> + actualAllocationSegments?: Array<{ + startWeek: number + endWeek: number + days: number + }> + synthetic?: boolean } export interface TimelineSummary { @@ -266,6 +280,20 @@ export interface ResourceProfileRow { allocatedDays: number derivedStartWeek: number | null derivedEndWeek: number | null + actualAllocatedDays: number + actualAllocationStartWeek: number | null + actualAllocationEndWeek: number | null + actualAllocatedWeeks: Array<{ + week: number + days: number + capacityDays: number + }> + actualAllocationSegments: Array<{ + startWeek: number + endWeek: number + days: number + }> + synthetic: boolean }> } diff --git a/server/src/lib/capacityPlanExit.ts b/server/src/lib/capacityPlanExit.ts new file mode 100644 index 00000000..4a857b9c --- /dev/null +++ b/server/src/lib/capacityPlanExit.ts @@ -0,0 +1,29 @@ +import { prisma } from './prisma.js' + +export async function exitCapacityPlanForManualScheduling(resourceTypeId: string) { + await prisma.resourceType.update({ + where: { id: resourceTypeId }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + }) + + await prisma.namedResource.updateMany({ + where: { + resourceTypeId, + allocationMode: 'CAPACITY_PLAN', + }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + allocationPct: 100, + startWeek: null, + endWeek: null, + }, + }) +} diff --git a/server/src/lib/namedResourceAssignments.ts b/server/src/lib/namedResourceAssignments.ts new file mode 100644 index 00000000..6349af2c --- /dev/null +++ b/server/src/lib/namedResourceAssignments.ts @@ -0,0 +1,310 @@ +import { + shouldFallbackToActiveCapacityPlan, + type MaterializedCapacityPlanResource, +} from './capacityPlanMaterialisation.js' + +type AllocationMode = 'EFFORT' | 'TIMELINE' | 'FULL_PROJECT' | 'CAPACITY_PLAN' + +type NamedResourceLike = { + id: string + name: string + startWeek: number | null + endWeek: number | null + allocationPct?: number | null + allocationMode?: string | null + allocationPercent?: number | null + allocationStartWeek?: number | null + allocationEndWeek?: number | null +} + +type ResourceTypeLike = { + id: string + name: string + count: number + allocationMode?: string | null + namedResources?: NamedResourceLike[] +} + +export type WeeklyDemandLike = { + week: number + resourceTypeName: string + demandDays: number +} + +export type NamedResourceAssignedWeek = { + week: number + days: number + capacityDays: number +} + +export type NamedResourceAssignedSegment = { + startWeek: number + endWeek: number + days: number +} + +export type DerivedNamedResourceAssignment = { + id: string + resourceTypeId: string + resourceTypeName: string + name: string + allocationMode: string + allocationPercent: number + allocationStartWeek: number | null + allocationEndWeek: number | null + startWeek: number | null + endWeek: number | null + synthetic: boolean + actualAllocatedDays: number + actualAllocationStartWeek: number | null + actualAllocationEndWeek: number | null + actualAllocatedWeeks: NamedResourceAssignedWeek[] + actualAllocationSegments: NamedResourceAssignedSegment[] +} + +type DerivedResourceTypeAssignment = { + resourceTypeId: string + resourceTypeName: string + actualAllocatedDays: number + unallocatedDays: number + namedResources: DerivedNamedResourceAssignment[] +} + +type DeriveNamedResourceAssignmentsInput = { + resourceTypes: ResourceTypeLike[] + weeklyDemand: WeeklyDemandLike[] + capacityPlanByRt?: Map +} + +type WorkingNamedResource = DerivedNamedResourceAssignment & { + order: number + lastAssignedWeek: number | null +} + +const FLOAT_EPSILON = 0.000001 + +const round2 = (value: number) => Math.round(value * 100) / 100 + +function toAllocationMode(mode: string | null | undefined): AllocationMode { + return (mode as AllocationMode | null) ?? 'EFFORT' +} + +function buildEffectiveNamedResources( + resourceType: ResourceTypeLike, + hasDemand: boolean, + capacityPlanByRt: Map, +): WorkingNamedResource[] { + const persistedNamedResources = resourceType.namedResources ?? [] + const mode = toAllocationMode(resourceType.allocationMode) + const capacityPlanMaterialized = capacityPlanByRt.get(resourceType.id) + const useCapacityPlanFallback = + mode === 'CAPACITY_PLAN' && + shouldFallbackToActiveCapacityPlan(persistedNamedResources, capacityPlanMaterialized) + + const baseNamedResources = useCapacityPlanFallback && capacityPlanMaterialized + ? capacityPlanMaterialized.slotWindows.map((window, idx) => { + const existing = persistedNamedResources[idx] + return { + id: existing?.id ?? `${resourceType.id}-capacity-plan-${idx + 1}`, + name: existing?.name ?? `${resourceType.name} ${idx + 1}`, + startWeek: window.startWeek, + endWeek: window.endWeek, + allocationPct: window.allocationPercent, + allocationMode: 'CAPACITY_PLAN', + allocationPercent: window.allocationPercent, + allocationStartWeek: null, + allocationEndWeek: null, + synthetic: !existing, + } + }) + : persistedNamedResources.map(namedResource => ({ + ...namedResource, + synthetic: false, + })) + + const effectiveCount = Math.max(resourceType.count ?? 0, baseNamedResources.length) + const namedResources = hasDemand && effectiveCount > baseNamedResources.length + ? [ + ...baseNamedResources, + ...Array.from({ length: effectiveCount - baseNamedResources.length }, (_, offset) => ({ + id: `${resourceType.id}-synthetic-${baseNamedResources.length + offset + 1}`, + name: `${resourceType.name} ${baseNamedResources.length + offset + 1}`, + startWeek: null, + endWeek: null, + allocationPct: 100, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + synthetic: true, + })), + ] + : baseNamedResources + + return namedResources.map((namedResource, order) => ({ + id: namedResource.id, + resourceTypeId: resourceType.id, + resourceTypeName: resourceType.name, + name: namedResource.name, + allocationMode: namedResource.allocationMode ?? 'EFFORT', + allocationPercent: namedResource.allocationPercent ?? namedResource.allocationPct ?? 100, + allocationStartWeek: namedResource.allocationStartWeek ?? null, + allocationEndWeek: namedResource.allocationEndWeek ?? null, + startWeek: namedResource.startWeek ?? null, + endWeek: namedResource.endWeek ?? null, + synthetic: namedResource.synthetic, + actualAllocatedDays: 0, + actualAllocationStartWeek: null, + actualAllocationEndWeek: null, + actualAllocatedWeeks: [], + actualAllocationSegments: [], + order, + lastAssignedWeek: null, + })) +} + +function weeklyCapacityForNamedResource( + namedResource: DerivedNamedResourceAssignment, + week: number, +): number { + const startWeek = namedResource.startWeek ?? 0 + const endWeek = namedResource.endWeek ?? Infinity + + if (week < startWeek || week > endWeek) return 0 + + const mode = toAllocationMode(namedResource.allocationMode) + const allocationPercent = namedResource.allocationPercent ?? 100 + + if (mode === 'EFFORT') return 5 + if (mode === 'FULL_PROJECT' || mode === 'CAPACITY_PLAN') return 5 * (allocationPercent / 100) + + const effectiveStartWeek = namedResource.allocationStartWeek ?? namedResource.startWeek ?? 0 + const effectiveEndWeek = + namedResource.allocationEndWeek ?? + namedResource.endWeek ?? + effectiveStartWeek + + if (week < effectiveStartWeek || week > effectiveEndWeek) return 0 + return 5 * (allocationPercent / 100) +} + +function buildSegments(weeks: NamedResourceAssignedWeek[]): NamedResourceAssignedSegment[] { + if (weeks.length === 0) return [] + + const sortedWeeks = [...weeks].sort((a, b) => a.week - b.week) + const segments: NamedResourceAssignedSegment[] = [] + let current: NamedResourceAssignedSegment = { + startWeek: sortedWeeks[0].week, + endWeek: sortedWeeks[0].week, + days: round2(sortedWeeks[0].days), + } + + for (let index = 1; index < sortedWeeks.length; index += 1) { + const week = sortedWeeks[index] + if (week.week === current.endWeek + 1) { + current.endWeek = week.week + current.days = round2(current.days + week.days) + continue + } + + segments.push(current) + current = { + startWeek: week.week, + endWeek: week.week, + days: round2(week.days), + } + } + + segments.push(current) + return segments +} + +export function deriveNamedResourceAssignments({ + resourceTypes, + weeklyDemand, + capacityPlanByRt = new Map(), +}: DeriveNamedResourceAssignmentsInput): Map { + const weeklyDemandByResourceType = new Map() + + for (const row of weeklyDemand) { + if (!Number.isFinite(row.demandDays) || row.demandDays <= 0) continue + if (!weeklyDemandByResourceType.has(row.resourceTypeName)) { + weeklyDemandByResourceType.set(row.resourceTypeName, []) + } + weeklyDemandByResourceType.get(row.resourceTypeName)!.push(row) + } + + const assignmentsByResourceType = new Map() + + for (const resourceType of resourceTypes) { + const demandRows = [...(weeklyDemandByResourceType.get(resourceType.name) ?? [])] + .sort((a, b) => a.week - b.week) + + const namedResources = buildEffectiveNamedResources( + resourceType, + demandRows.length > 0, + capacityPlanByRt, + ) + + let unallocatedDays = 0 + + for (const demandRow of demandRows) { + let remainingDemand = demandRow.demandDays + const capacityRows = namedResources + .map(namedResource => ({ + namedResource, + capacityDays: weeklyCapacityForNamedResource(namedResource, demandRow.week), + })) + .filter(row => row.capacityDays > FLOAT_EPSILON) + .sort((left, right) => { + const leftContinues = left.namedResource.lastAssignedWeek === demandRow.week - 1 ? 1 : 0 + const rightContinues = right.namedResource.lastAssignedWeek === demandRow.week - 1 ? 1 : 0 + if (leftContinues !== rightContinues) return rightContinues - leftContinues + if (left.namedResource.synthetic !== right.namedResource.synthetic) { + return Number(left.namedResource.synthetic) - Number(right.namedResource.synthetic) + } + if (Math.abs(left.namedResource.actualAllocatedDays - right.namedResource.actualAllocatedDays) > FLOAT_EPSILON) { + return left.namedResource.actualAllocatedDays - right.namedResource.actualAllocatedDays + } + return left.namedResource.order - right.namedResource.order + }) + + for (const { namedResource, capacityDays } of capacityRows) { + if (remainingDemand <= FLOAT_EPSILON) break + const allocatedDays = Math.min(remainingDemand, capacityDays) + if (allocatedDays <= FLOAT_EPSILON) continue + + namedResource.actualAllocatedDays = round2(namedResource.actualAllocatedDays + allocatedDays) + namedResource.lastAssignedWeek = demandRow.week + namedResource.actualAllocatedWeeks.push({ + week: demandRow.week, + days: round2(allocatedDays), + capacityDays: round2(capacityDays), + }) + remainingDemand -= allocatedDays + } + + if (remainingDemand > FLOAT_EPSILON) { + unallocatedDays = round2(unallocatedDays + remainingDemand) + } + } + + for (const namedResource of namedResources) { + const sortedWeeks = [...namedResource.actualAllocatedWeeks].sort((a, b) => a.week - b.week) + namedResource.actualAllocatedWeeks = sortedWeeks + namedResource.actualAllocationSegments = buildSegments(sortedWeeks) + namedResource.actualAllocationStartWeek = sortedWeeks[0]?.week ?? null + namedResource.actualAllocationEndWeek = sortedWeeks[sortedWeeks.length - 1]?.week ?? null + } + + assignmentsByResourceType.set(resourceType.id, { + resourceTypeId: resourceType.id, + resourceTypeName: resourceType.name, + actualAllocatedDays: round2(namedResources.reduce((sum, namedResource) => sum + namedResource.actualAllocatedDays, 0)), + unallocatedDays, + namedResources, + }) + } + + return assignmentsByResourceType +} diff --git a/server/src/lib/sa-planner.ts b/server/src/lib/sa-planner.ts index 4f1bf477..49e1fa35 100644 --- a/server/src/lib/sa-planner.ts +++ b/server/src/lib/sa-planner.ts @@ -194,22 +194,110 @@ export function runSAPlanner( } } - const roughDurationWeeks = (() => { + function getSizingCapacityDays(rt: SchedulerResourceType, week: number): number { + const hoursPerDay = rt.hoursPerDay ?? hpd + if (hoursPerDay <= 0) return 0 + + let capacityDays = getWeeklyCapacity(rt, week, hpd) / hoursPerDay + if (!Number.isFinite(capacityDays) || capacityDays <= 0) return 0 + + const capCount = maxCap?.get(rt.id) + if (capCount != null && rt.count > 0 && capCount < rt.count) { + capacityDays *= capCount / rt.count + } + + return Math.max(0, capacityDays) + } + + function estimateAvailabilityProbeWeeks(rt: SchedulerResourceType, durationWeeks: number): number { + const latestNamedWindowWeek = (rt.namedResources ?? []).reduce((latest, nr) => { + return Math.max( + latest, + nr.startWeek ?? 0, + nr.endWeek ?? 0, + nr.allocationStartWeek ?? 0, + nr.allocationEndWeek ?? 0, + ) + }, 0) + + return Math.max( + 52, + Math.ceil(targetDurationWeeks * 3), + Math.ceil(durationWeeks * 4) + features.length + 12, + latestNamedWindowWeek + Math.ceil(targetDurationWeeks * 2) + 12, + ) + } + + function estimateFeatureDurationWeeks( + daysByRt: Map, + capByRt?: Map, + ): number { + let durationWeeks = 0 + for (const [rtId, days] of daysByRt) { + if (days <= EPSILON) continue + const weeklyCap = capByRt?.get(rtId) ?? days + if (weeklyCap <= EPSILON) continue + durationWeeks = Math.max(durationWeeks, days / weeklyCap) + } + + return Math.max(1, durationWeeks) + } + + const serialFeatureDurationWeeks = features.reduce((total, feature) => { + if (!feature.hasDemand) return total + const capByRt = featureWeeklyCapByRt.get(feature.id) + return total + estimateFeatureDurationWeeks(feature.totalDaysByRt, capByRt) + }, 0) + + const bottleneckFeatureDurationWeeks = features.reduce((maxWeeks, feature) => { + if (!feature.hasDemand) return maxWeeks + const capByRt = featureWeeklyCapByRt.get(feature.id) + return Math.max(maxWeeks, estimateFeatureDurationWeeks(feature.totalDaysByRt, capByRt)) + }, 0) + + const aggregateDurationWithDelayWeeks = (() => { let maxWeeks = 0 + let maxAvailabilityDelayWeeks = 0 for (const rt of resourceTypes) { const demand = totalDemandByRt.get(rt.id) ?? 0 if (demand <= EPSILON) continue - const people = Math.max(1, effectiveRtCount.get(rt.id) ?? rt.count ?? 1) - const weeks = demand / (people * 5) + const people = effectiveRtCount.get(rt.id) ?? rt.count ?? 0 + if (people <= EPSILON) continue + + const demandDurationWeeks = demand / (people * 5) + const sizingDurationWeeks = Math.max( + demandDurationWeeks, + bottleneckFeatureDurationWeeks, + serialFeatureDurationWeeks, + ) + const probeWeeks = estimateAvailabilityProbeWeeks(rt, sizingDurationWeeks) + let availabilityDelayWeeks = probeWeeks + + for (let week = 0; week <= probeWeeks; week++) { + if (getSizingCapacityDays(rt, week) > EPSILON) { + availabilityDelayWeeks = week + break + } + } + + const weeks = demandDurationWeeks + availabilityDelayWeeks if (weeks > maxWeeks) maxWeeks = weeks + if (availabilityDelayWeeks > maxAvailabilityDelayWeeks) { + maxAvailabilityDelayWeeks = availabilityDelayWeeks + } } - return maxWeeks + + return Math.max( + maxWeeks, + serialFeatureDurationWeeks + maxAvailabilityDelayWeeks, + bottleneckFeatureDurationWeeks + maxAvailabilityDelayWeeks, + ) })() const MAX_WEEKS = Math.max( 52, Math.ceil(targetDurationWeeks * 3), - Math.ceil(roughDurationWeeks * 4) + features.length + 12, + Math.ceil(aggregateDurationWithDelayWeeks * 4) + features.length + 12, ) let completedFeatureCount = 0 @@ -288,6 +376,11 @@ export function runSAPlanner( return a.id.localeCompare(b.id) } + function estimateFeatureRemainingWeeks(feature: FeatureInfo): number { + const capByRt = featureWeeklyCapByRt.get(feature.id) + return estimateFeatureDurationWeeks(feature.remainingDaysByRt, capByRt) + } + function recordAllocation(feature: FeatureInfo, rtId: string, week: number, days: number) { const byWeek = weeklyAllocationsByFeature.get(feature.id) if (!byWeek) return @@ -324,6 +417,11 @@ export function runSAPlanner( let availableCapacity = getWeeklyCapacityDays(rt, week) if (availableCapacity <= EPSILON) continue + const estimatedFeatureWeeks = new Map() + for (const feature of readyFeatures) { + estimatedFeatureWeeks.set(feature.id, estimateFeatureRemainingWeeks(feature)) + } + const candidates = readyFeatures .filter(feature => (feature.remainingDaysByRt.get(rt.id) ?? 0) > EPSILON) .sort(compareFeatures) @@ -343,9 +441,11 @@ export function runSAPlanner( ) if (perFeatureCap <= EPSILON) continue - const smoothedTarget = week < targetDurationWeeks - ? Math.min(perFeatureCap, remaining / weeksLeftToTarget) - : perFeatureCap + const featureRemainingWeeks = estimatedFeatureWeeks.get(feature.id) ?? 1 + const pacingWeeks = week < targetDurationWeeks + ? Math.max(1, Math.min(weeksLeftToTarget, featureRemainingWeeks)) + : 1 + const smoothedTarget = Math.min(perFeatureCap, remaining / pacingWeeks) const allocation = Math.min(availableCapacity, smoothedTarget) if (allocation <= EPSILON) continue @@ -354,12 +454,12 @@ export function runSAPlanner( availableCapacity -= allocation } - // Baseline allocation smooths work toward the target window, but any - // remaining idle RT capacity should still be consumed by ready work up to - // the per-feature cap. Withholding spare capacity when only one feature - // is ready creates artificial dribble schedules, low utilisation, and - // distorted headcount envelopes. - if (availableCapacity > EPSILON) { + // Continuity-aware pacing deliberately avoids spending all spare RT + // capacity whenever multiple features compete for the same role. This + // keeps smaller role slices on long-running features from disappearing + // early while the feature remains active. We still top up when there is + // only one ready candidate so blockers can clear quickly. + if (availableCapacity > EPSILON && candidates.length === 1) { for (const feature of candidates) { if (availableCapacity <= EPSILON) break diff --git a/server/src/routes/namedResources.ts b/server/src/routes/namedResources.ts index bee170ad..77002e20 100644 --- a/server/src/routes/namedResources.ts +++ b/server/src/routes/namedResources.ts @@ -3,6 +3,7 @@ import { AllocationMode } from '@prisma/client' import { prisma } from '../lib/prisma.js' import { asyncHandler } from '../lib/asyncHandler.js' import { authenticate, AuthRequest } from '../middleware/auth.js' +import { exitCapacityPlanForManualScheduling } from '../lib/capacityPlanExit.js' const router = Router({ mergeParams: true }) router.use(authenticate) @@ -44,6 +45,10 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { const rt = await verifyResourceType(rtId, projectId) if (!rt) { res.status(404).json({ error: 'Resource type not found' }); return } + if (rt.allocationMode === 'CAPACITY_PLAN') { + await exitCapacityPlanForManualScheduling(rt.id) + } + const { name: rawName, startWeek, endWeek, allocationPct, pricingModel } = req.body // Auto-generate a numbered name if none provided or generic @@ -62,7 +67,12 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { } // If RT's allocationMode is not EFFORT, copy the RT's allocation settings as defaults for the new NR - const rtAllocationMode = rt.allocationMode as AllocationMode + const rtAllocationMode = rt.allocationMode === 'CAPACITY_PLAN' + ? 'TIMELINE' + : rt.allocationMode as AllocationMode + const rtAllocationPercent = rt.allocationMode === 'CAPACITY_PLAN' ? 100 : (rt.allocationPercent ?? 100) + const rtAllocationStartWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationStartWeek ?? null) + const rtAllocationEndWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationEndWeek ?? null) const inheritAllocation = rtAllocationMode !== 'EFFORT' const resource = await prisma.namedResource.create({ @@ -75,9 +85,9 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { ...(pricingModel !== undefined && { pricingModel }), ...(inheritAllocation && { allocationMode: rtAllocationMode, - allocationPercent: rt.allocationPercent ?? 100, - allocationStartWeek: rt.allocationStartWeek ?? null, - allocationEndWeek: rt.allocationEndWeek ?? null, + allocationPercent: rtAllocationPercent, + allocationStartWeek: rtAllocationStartWeek, + allocationEndWeek: rtAllocationEndWeek, }), }, }) @@ -163,6 +173,10 @@ router.delete('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { const existing = await prisma.namedResource.findFirst({ where: { id, resourceTypeId: rtId } }) if (!existing) { res.status(404).json({ error: 'Named resource not found' }); return } + if (rt.allocationMode === 'CAPACITY_PLAN') { + await exitCapacityPlanForManualScheduling(rt.id) + } + await prisma.namedResource.delete({ where: { id } }) // Sync resource type count (can reach 0 when all named resources are deleted) diff --git a/server/src/routes/resourceProfile.ts b/server/src/routes/resourceProfile.ts index 0648d83f..0906b87a 100644 --- a/server/src/routes/resourceProfile.ts +++ b/server/src/routes/resourceProfile.ts @@ -7,6 +7,7 @@ import { materializeCapacityPlanResources, shouldFallbackToActiveCapacityPlan, } from '../lib/capacityPlanMaterialisation.js' +import { deriveNamedResourceAssignments, type WeeklyDemandLike } from '../lib/namedResourceAssignments.js' type AllocationMode = 'EFFORT' | 'TIMELINE' | 'FULL_PROJECT' | 'CAPACITY_PLAN' @@ -67,6 +68,7 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { const fallbackHoursPerDay = project.hoursPerDay const resourceTypeById = new Map(project.resourceTypes.map(rt => [rt.id, rt])) + const resourceTypeNameById = new Map(project.resourceTypes.map(rt => [rt.id, rt.name])) // Build a map: rtId → total allocated days from the active capacity plan const activePlan = project.capacityPlans?.[0] ?? null @@ -208,6 +210,74 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { } } + const weeklyDemandMap = new Map() + const addWeeklyDemand = (week: number, resourceTypeName: string, demandDays: number) => { + if (!Number.isFinite(demandDays) || demandDays <= 0) return + const key = `${week}|${resourceTypeName}` + weeklyDemandMap.set(key, round2((weeklyDemandMap.get(key) ?? 0) + demandDays)) + } + + for (const epic of project.epics) { + if (epic.isActive === false) continue + for (const feature of epic.features) { + if (feature.isActive === false) continue + for (const story of feature.userStories) { + if (story.isActive === false) continue + const storyEntry = storyEntryMap.get(story.id) + const featureEntry = featureEntryMap.get(feature.id) + const entry = storyEntry ?? featureEntry + if (!entry || entry.durationWeeks <= 0) continue + + for (const task of story.tasks) { + if (!task.resourceTypeId) continue + const resourceTypeName = resourceTypeNameById.get(task.resourceTypeId) + if (!resourceTypeName) continue + const resourceType = resourceTypeById.get(task.resourceTypeId) + const effectiveHoursPerDay = + resourceType?.hoursPerDay && resourceType.hoursPerDay > 0 + ? resourceType.hoursPerDay + : fallbackHoursPerDay + if (!effectiveHoursPerDay) continue + + const demandDays = task.durationDays ?? ((task.hoursEffort ?? 0) / effectiveHoursPerDay) + const startWeek = entry.startWeek + const endWeek = entry.startWeek + entry.durationWeeks + for (let week = Math.floor(startWeek); week < Math.ceil(endWeek); week += 1) { + const overlap = Math.min(week + 1, endWeek) - Math.max(week, startWeek) + if (overlap <= 0) continue + addWeeklyDemand(week, resourceTypeName, demandDays * (overlap / entry.durationWeeks)) + } + } + } + } + } + + if (project.weeklyDemandCache && Object.keys(project.weeklyDemandCache as Record).length > 0) { + for (const [key, demandDays] of Object.entries(project.weeklyDemandCache as Record)) { + const separatorIdx = key.lastIndexOf('|') + if (separatorIdx === -1) continue + const resourceTypeName = key.substring(0, separatorIdx) + const week = Number(key.substring(separatorIdx + 1)) + if (!Number.isFinite(week) || !Number.isFinite(demandDays) || demandDays <= 0) continue + weeklyDemandMap.set(`${week}|${resourceTypeName}`, round2(demandDays)) + } + } + + const weeklyDemand: WeeklyDemandLike[] = Array.from(weeklyDemandMap.entries()).map(([key, demandDays]) => { + const separatorIdx = key.indexOf('|') + return { + week: Number(key.substring(0, separatorIdx)), + resourceTypeName: key.substring(separatorIdx + 1), + demandDays, + } + }) + + const namedResourceAssignments = deriveNamedResourceAssignments({ + resourceTypes: project.resourceTypes, + weeklyDemand, + capacityPlanByRt, + }) + const categoryIndex = (category: ResourceCategory) => { const idx = CATEGORY_ORDER.indexOf(category) return idx === -1 ? CATEGORY_ORDER.length : idx @@ -301,11 +371,20 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { allocatedDays: number derivedStartWeek: number | null derivedEndWeek: number | null + actualAllocatedDays: number + actualAllocationStartWeek: number | null + actualAllocationEndWeek: number | null + actualAllocatedWeeks: Array<{ week: number; days: number; capacityDays: number }> + actualAllocationSegments: Array<{ startWeek: number; endWeek: number; days: number }> + synthetic: boolean }> if (hasNamedResources) { // Compute per-NR allocated days namedResourcesOutput = namedResourcesSource.map(nr => { + const actualNamedResource = namedResourceAssignments + .get(resourceType.id) + ?.namedResources.find(actual => actual.id === nr.id || actual.name === nr.name) const nrMode = (nr.allocationMode as AllocationMode) ?? 'EFFORT' const nrPercent = nr.allocationPercent ?? 100 let nrAllocatedDays: number @@ -339,8 +418,37 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { allocatedDays: nrAllocatedDays, derivedStartWeek, derivedEndWeek, + actualAllocatedDays: actualNamedResource?.actualAllocatedDays ?? 0, + actualAllocationStartWeek: actualNamedResource?.actualAllocationStartWeek ?? null, + actualAllocationEndWeek: actualNamedResource?.actualAllocationEndWeek ?? null, + actualAllocatedWeeks: actualNamedResource?.actualAllocatedWeeks ?? [], + actualAllocationSegments: actualNamedResource?.actualAllocationSegments ?? [], + synthetic: actualNamedResource?.synthetic ?? false, } }) + const existingIds = new Set(namedResourcesOutput.map(nr => nr.id)) + const syntheticAssignments = namedResourceAssignments + .get(resourceType.id) + ?.namedResources.filter(actual => !existingIds.has(actual.id)) ?? [] + namedResourcesOutput.push(...syntheticAssignments.map(actual => ({ + id: actual.id, + name: actual.name, + allocationMode: actual.allocationMode, + allocationPercent: actual.allocationPercent, + allocationStartWeek: actual.allocationStartWeek, + allocationEndWeek: actual.allocationEndWeek, + startWeek: actual.startWeek, + endWeek: actual.endWeek, + allocatedDays: actual.actualAllocatedDays, + derivedStartWeek, + derivedEndWeek, + actualAllocatedDays: actual.actualAllocatedDays, + actualAllocationStartWeek: actual.actualAllocationStartWeek, + actualAllocationEndWeek: actual.actualAllocationEndWeek, + actualAllocatedWeeks: actual.actualAllocatedWeeks, + actualAllocationSegments: actual.actualAllocationSegments, + synthetic: actual.synthetic, + }))) // Total RT allocatedDays = sum of NR allocatedDays allocatedDays = round2(namedResourcesOutput.reduce((sum, nr) => sum + nr.allocatedDays, 0)) } else { diff --git a/server/src/routes/resourceTypes.ts b/server/src/routes/resourceTypes.ts index 6b74cc42..39c6f352 100644 --- a/server/src/routes/resourceTypes.ts +++ b/server/src/routes/resourceTypes.ts @@ -3,6 +3,7 @@ import { prisma } from '../lib/prisma.js' import { asyncHandler } from '../lib/asyncHandler.js' import { authenticate, AuthRequest } from '../middleware/auth.js' import { ownedProject } from '../lib/ownership.js' +import { exitCapacityPlanForManualScheduling } from '../lib/capacityPlanExit.js' const router = Router({ mergeParams: true }) router.use(authenticate) @@ -55,6 +56,14 @@ router.put('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { if (!project) { res.status(404).json({ error: 'Project not found' }); return } const { name, category, count, proposedName, hoursPerDay, dayRate, allocationMode, allocationPercent, allocationStartWeek, allocationEndWeek } = req.body + const existing = await prisma.resourceType.findFirst({ + where: { + id: req.params.id as string, + projectId: req.params.projectId as string, + }, + }) + if (!existing) { res.status(404).json({ error: 'Resource type not found' }); return } + // Validate new allocation fields if (allocationMode !== undefined && !['EFFORT', 'TIMELINE', 'FULL_PROJECT'].includes(allocationMode)) { res.status(400).json({ error: 'Invalid allocationMode' }); return @@ -73,10 +82,39 @@ router.put('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { if ('allocationStartWeek' in req.body) data.allocationStartWeek = allocationStartWeek ?? null if ('allocationEndWeek' in req.body) data.allocationEndWeek = allocationEndWeek ?? null + const shouldExitCapacityPlan = + existing.allocationMode === 'CAPACITY_PLAN' && + allocationMode === undefined && + count !== undefined + + if (shouldExitCapacityPlan) { + data.allocationMode = 'TIMELINE' + data.allocationPercent = 100 + data.allocationStartWeek = null + data.allocationEndWeek = null + } + const rt = await prisma.resourceType.update({ where: { id: req.params.id as string }, data, }) + if (shouldExitCapacityPlan) { + await prisma.namedResource.updateMany({ + where: { + resourceTypeId: existing.id, + allocationMode: 'CAPACITY_PLAN', + }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + allocationPct: 100, + startWeek: null, + endWeek: null, + }, + }) + } res.json(rt) })) @@ -93,6 +131,15 @@ router.patch('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { const rt = await prisma.resourceType.findFirst({ where: { id: req.params.id as string, projectId: req.params.projectId as string } }) if (!rt) { res.status(404).json({ error: 'Resource type not found' }); return } + const defaultAllocationMode = rt.allocationMode === 'CAPACITY_PLAN' ? 'TIMELINE' : rt.allocationMode + const defaultAllocationPercent = rt.allocationMode === 'CAPACITY_PLAN' ? 100 : (rt.allocationPercent ?? 100) + const defaultAllocationStartWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationStartWeek ?? null) + const defaultAllocationEndWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationEndWeek ?? null) + + if (rt.allocationMode === 'CAPACITY_PLAN') { + await exitCapacityPlanForManualScheduling(rt.id) + } + const currentNRs = await prisma.namedResource.findMany({ where: { resourceTypeId: rt.id }, orderBy: { createdAt: 'asc' }, @@ -104,7 +151,17 @@ router.patch('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { // Add new anonymous named resources for each new slot for (let n = currentCount + 1; n <= count; n++) { await prisma.namedResource.create({ - data: { name: `${rt.name} ${n}`, resourceTypeId: rt.id, allocationPct: 100 }, + data: { + name: `${rt.name} ${n}`, + resourceTypeId: rt.id, + allocationPct: 100, + ...(defaultAllocationMode !== 'EFFORT' && { + allocationMode: defaultAllocationMode, + allocationPercent: defaultAllocationPercent, + allocationStartWeek: defaultAllocationStartWeek, + allocationEndWeek: defaultAllocationEndWeek, + }), + }, }) } } else if (count < currentCount) { diff --git a/server/src/routes/squadPlan.ts b/server/src/routes/squadPlan.ts index 35d21993..289615f8 100644 --- a/server/src/routes/squadPlan.ts +++ b/server/src/routes/squadPlan.ts @@ -122,6 +122,35 @@ function buildWeeklyDemandCacheFromPlannerResult( return weeklyDemandCache } +function buildReplayPlannerResourceTypes( + resourceTypes: SchedulerResourceType[], + slotWindowsByRt: Map, + maxHeadcountByRt: Map, +): SchedulerResourceType[] { + return resourceTypes.map(resourceType => { + const slotWindows = slotWindowsByRt.get(resourceType.id) + const maxHeadcount = maxHeadcountByRt.get(resourceType.id) + + if (!slotWindows || maxHeadcount == null) return resourceType + + return { + ...resourceType, + count: maxHeadcount, + namedResources: slotWindows.map((slotWindow, idx) => ({ + id: `capacity-plan-${resourceType.id}-${idx}`, + name: `${resourceType.name} ${idx + 1}`, + startWeek: slotWindow.startWeek, + endWeek: slotWindow.endWeek, + allocationPct: slotWindow.allocationPercent, + allocationMode: 'CAPACITY_PLAN', + allocationPercent: slotWindow.allocationPercent, + allocationStartWeek: null, + allocationEndWeek: null, + })), + } + }) +} + async function loadSchedulerInput( projectId: string, hoursPerDay: number, @@ -547,21 +576,22 @@ router.post('/apply', asyncHandler(async (req: AuthRequest, res: Response) => { const schedulerInput = await loadSchedulerInput(projectId, project.hoursPerDay, { includeCapacityPlanMaterialization: false, }) - const plannerMaxCap = new Map() - for (const [rtId, headcount] of maxHeadcountByRt.entries()) { - if (isNonNegativeFiniteNumber(headcount)) { - plannerMaxCap.set(rtId, headcount) - } - } - const plannerResult = runSAPlanner(schedulerInput, { + const replayResourceTypes = buildReplayPlannerResourceTypes( + schedulerInput.resourceTypes, + slotWindowsByRt, + maxHeadcountByRt, + ) + const plannerResult = runSAPlanner({ + ...schedulerInput, + resourceTypes: replayResourceTypes, + }, { targetDurationWeeks: targetWeeks, maxParallelismPerFeature: maxParallelism, - maxCap: plannerMaxCap, maxConcurrentEpics: clientMaxConcurrentEpics, }) refreshedWeeklyDemandCache = buildWeeklyDemandCacheFromPlannerResult( plannerResult.weeklyDemandByResourceType, - schedulerInput.resourceTypes, + replayResourceTypes, ) // Persist epic start weeks @@ -865,7 +895,25 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { maxConcurrentEpics: body.maxConcurrentEpics, } - const result = computeCapacityPlan(schedulerInput, config) + let result: ReturnType + try { + result = computeCapacityPlan(schedulerInput, config) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + const isGenerationFailure = + error instanceof Error && detail.includes('Fractional planner could not finish feature') + + if (isGenerationFailure) { + res.status(400).json({ + error: + 'No feasible squad plan found under the current constraints. Try resetting RT max caps, increasing max parallelism, or clearing saved planner settings. ' + + `Details: ${detail}`, + }) + return + } + + throw error + } // ── Serialise LevellingResult Maps for JSON transport ─────────────────── res.json({ diff --git a/server/src/routes/timeline.ts b/server/src/routes/timeline.ts index f728fa0e..bd780d7d 100644 --- a/server/src/routes/timeline.ts +++ b/server/src/routes/timeline.ts @@ -15,6 +15,7 @@ import { shouldFallbackToActiveCapacityPlan, type MaterializedCapacityPlanResource, } from '../lib/capacityPlanMaterialisation.js' +import { deriveNamedResourceAssignments } from '../lib/namedResourceAssignments.js' import { runSAPlanner } from '../lib/sa-planner.js' import { buildSnapshot } from './snapshots.js' import { pruneSnapshots } from '../lib/snapshotUtils.js' @@ -31,6 +32,7 @@ export { getWeeklyCapacity } // Alias for internal use within this file type AllocationMode = 'EFFORT' | 'TIMELINE' | 'FULL_PROJECT' | 'CAPACITY_PLAN' type ResourceTypeWithNamed = SchedulerResourceType & { allocationMode?: AllocationMode | null } +type WeeklyDemandRow = { week: number; resourceTypeName: string; demandDays: number; capacityDays: number } async function ownedProject(projectId: string, userId: string) { return prisma.project.findFirst({ where: { id: projectId, ownerId: userId } }) @@ -143,30 +145,18 @@ function buildResponse( return getWeeklyCapacity(rt, week, project.hoursPerDay) / hpd } - // Compute weekly demand across all features - let weeklyDemand: { week: number; resourceTypeName: string; demandDays: number; capacityDays: number }[] + const weeklyDemandKey = (week: number, resourceTypeName: string) => `${week}|${resourceTypeName}` + const weeklyDemandSort = (a: WeeklyDemandRow, b: WeeklyDemandRow) => + a.week - b.week || a.resourceTypeName.localeCompare(b.resourceTypeName) + const parseSimulatedDemandKey = (key: string) => { + const separatorIdx = key.lastIndexOf('|') + return { + resourceTypeName: key.substring(0, separatorIdx), + week: parseInt(key.substring(separatorIdx + 1), 10), + } + } - if (simulatedDemand && simulatedDemand.size > 0) { - // Use actual consumption from simulation — accurate, never exceeds capacity - weeklyDemand = Array.from(simulatedDemand.entries()) - .map(([key, days]) => { - const separatorIdx = key.lastIndexOf('|') - const rtName = key.substring(0, separatorIdx) - const week = parseInt(key.substring(separatorIdx + 1), 10) - const rt = rtByName.get(rtName) - const capacityDays = rt - ? capacityDaysForWeek(rt, week) - : 5 - return { - week, - resourceTypeName: rtName, - demandDays: Math.round(days * 100) / 100, - capacityDays, - } - }) - .filter(d => d.demandDays > 0) - .sort((a, b) => a.week - b.week || a.resourceTypeName.localeCompare(b.resourceTypeName)) - } else { + const buildFallbackWeeklyDemand = (): WeeklyDemandRow[] => { // Fallback: uniform spread (used by GET route with saved entries) const weeklyDemandMap = new Map() for (const e of entries) { @@ -182,7 +172,7 @@ function buildResponse( // Only count the fraction of this integer week the feature actually occupies const overlap = Math.min(w + 1, featureEnd) - Math.max(w, featureStart) if (overlap <= 0) continue - const key = `${w}|${name}` + const key = weeklyDemandKey(w, name) // Variable capacity: use named resource availability for this week const capacityDays = rt ? capacityDaysForWeek(rt, w) @@ -193,7 +183,7 @@ function buildResponse( } } } - weeklyDemand = Array.from(weeklyDemandMap.entries()).map(([key, { demandDays, capacityDays }]) => { + return Array.from(weeklyDemandMap.entries()).map(([key, { demandDays, capacityDays }]) => { const [weekStr, ...nameParts] = key.split('|') return { week: parseInt(weekStr, 10), @@ -201,7 +191,54 @@ function buildResponse( demandDays: Math.round(demandDays * 10) / 10, capacityDays, } - }).sort((a, b) => a.week - b.week || a.resourceTypeName.localeCompare(b.resourceTypeName)) + }).sort(weeklyDemandSort) + } + + // Compute weekly demand across all features + let weeklyDemand: WeeklyDemandRow[] + + if (simulatedDemand && simulatedDemand.size > 0) { + // Use actual consumption from simulation where present. For RTs with cached demand, + // missing weeks up to the project's global cached horizon are treated as zero demand + // rather than being reintroduced from fallback spread. + const fallbackDemand = buildFallbackWeeklyDemand() + const cachedResourceTypes = new Set() + let globalCachedMaxWeek = Number.NEGATIVE_INFINITY + + for (const key of simulatedDemand.keys()) { + const { resourceTypeName, week } = parseSimulatedDemandKey(key) + cachedResourceTypes.add(resourceTypeName) + globalCachedMaxWeek = Math.max(globalCachedMaxWeek, week) + } + + const mergedDemand = new Map() + + for (const row of fallbackDemand) { + const hasCachedDemand = cachedResourceTypes.has(row.resourceTypeName) + if (hasCachedDemand && row.week <= globalCachedMaxWeek) continue + + mergedDemand.set(weeklyDemandKey(row.week, row.resourceTypeName), row) + } + + for (const [key, days] of simulatedDemand.entries()) { + const { resourceTypeName: rtName, week } = parseSimulatedDemandKey(key) + const rt = rtByName.get(rtName) + const capacityDays = rt + ? capacityDaysForWeek(rt, week) + : 5 + mergedDemand.set(weeklyDemandKey(week, rtName), { + week, + resourceTypeName: rtName, + demandDays: Math.round(days * 100) / 100, + capacityDays, + }) + } + + weeklyDemand = Array.from(mergedDemand.values()) + .filter(d => d.demandDays > 0) + .sort(weeklyDemandSort) + } else { + weeklyDemand = buildFallbackWeeklyDemand() } // Build weekly capacity array for EVERY week (0..maxWeek-1) for RTs that have hours @@ -222,101 +259,37 @@ function buildResponse( } } - // Build derived weeks per RT for display (Bug #8) - const rtDerivedWeeks = new Map() - for (const d of weeklyDemand) { - const rtName = d.resourceTypeName - const week = d.week - const existing = rtDerivedWeeks.get(rtName) - if (!existing) { - rtDerivedWeeks.set(rtName, { start: week, end: week }) - } else { - existing.start = Math.min(existing.start, week) - existing.end = Math.max(existing.end, week) - } - } + const namedResourceAssignments = deriveNamedResourceAssignments({ + resourceTypes, + weeklyDemand, + capacityPlanByRt, + }) - // Build named resources list from resource types, auto-generating numbered - // entries for RTs with count > 0 but no named resources that have demand. - // Bug #7: filter to only include NRs where the RT actually has demand const namedResourcesList = resourceTypes - .filter(rt => rt.namedResources && rt.namedResources.length > 0 ? rtNamesWithHours.has(rt.name) : true) - .flatMap(rt => { - // Bug #7: only include if RT has demand - if (!rtNamesWithHours.has(rt.name)) return [] - - const derivedRt = rtDerivedWeeks.get(rt.name) - const capacityPlanMaterialized = capacityPlanByRt.get(rt.id) - const useCapacityPlanFallback = - (rt.allocationMode as AllocationMode | null) === 'CAPACITY_PLAN' && - shouldFallbackToActiveCapacityPlan(rt.namedResources ?? [], capacityPlanMaterialized) - - if (useCapacityPlanFallback && capacityPlanMaterialized) { - return capacityPlanMaterialized.slotWindows.map((window, idx) => { - const existing = rt.namedResources[idx] - return { - id: existing?.id ?? `${rt.id}-capacity-plan-${idx + 1}`, - resourceTypeId: rt.id, - resourceTypeName: rt.name, - name: existing?.name ?? `${rt.name} ${idx + 1}`, - startWeek: window.startWeek, - endWeek: window.endWeek, - allocationPct: window.allocationPercent, - allocationMode: 'CAPACITY_PLAN', - allocationPercent: window.allocationPercent, - allocationStartWeek: null, - allocationEndWeek: null, - } - }) - } - - if (rt.namedResources && rt.namedResources.length > 0) { - - return rt.namedResources.map(nr => { - const nrMode = (nr.allocationMode as AllocationMode | null) ?? 'EFFORT' - const isFullProject = nrMode === 'FULL_PROJECT' - const isTimeline = nrMode === 'TIMELINE' - const isCapacityPlan = nrMode === 'CAPACITY_PLAN' - return { - id: nr.id, - resourceTypeId: rt.id, - resourceTypeName: rt.name, - name: nr.name, - startWeek: isFullProject - ? null - : isTimeline - ? (nr.allocationStartWeek ?? (derivedRt?.start ?? null)) - : isCapacityPlan - ? (nr.startWeek ?? null) - : null, - endWeek: isFullProject - ? null - : isTimeline - ? (nr.allocationEndWeek ?? (derivedRt?.end ?? null)) - : isCapacityPlan - ? (nr.endWeek ?? null) - : null, - allocationPct: nr.allocationMode === 'EFFORT' ? 100 : Math.round(nr.allocationPercent), - allocationMode: nr.allocationMode, - allocationPercent: nr.allocationPercent ?? 100, - allocationStartWeek: nr.allocationStartWeek ?? null, - allocationEndWeek: nr.allocationEndWeek ?? null, - } - }) - } - // Auto-generate synthetic named resources when RT has count > 0 and demand - if (rt.count > 0 && rtNamesWithHours.has(rt.name)) { - return Array.from({ length: rt.count }, (_, i) => ({ - resourceTypeName: rt.name, - name: `${rt.name} ${i + 1}`, - startWeek: null as number | null, - endWeek: null as number | null, - allocationPct: 100, - allocationMode: 'EFFORT' as string, - })) - } - return [] - }) + .filter(rt => rtNamesWithHours.has(rt.name)) + .flatMap(rt => ( + namedResourceAssignments.get(rt.id)?.namedResources.map(namedResource => ({ + id: namedResource.id, + resourceTypeId: rt.id, + resourceTypeName: rt.name, + name: namedResource.name, + startWeek: namedResource.startWeek, + endWeek: namedResource.endWeek, + allocationPct: namedResource.allocationMode === 'EFFORT' + ? 100 + : Math.round(namedResource.allocationPercent), + allocationMode: namedResource.allocationMode, + allocationPercent: namedResource.allocationPercent, + allocationStartWeek: namedResource.allocationStartWeek, + allocationEndWeek: namedResource.allocationEndWeek, + actualAllocatedDays: namedResource.actualAllocatedDays, + actualAllocationStartWeek: namedResource.actualAllocationStartWeek, + actualAllocationEndWeek: namedResource.actualAllocationEndWeek, + actualAllocatedWeeks: namedResource.actualAllocatedWeeks, + actualAllocationSegments: namedResource.actualAllocationSegments, + synthetic: namedResource.synthetic, + })) ?? [] + )) return { projectId: project.id, diff --git a/server/src/test/resourceProfile.test.ts b/server/src/test/resourceProfile.test.ts index 0cb6b8d1..742651ed 100644 --- a/server/src/test/resourceProfile.test.ts +++ b/server/src/test/resourceProfile.test.ts @@ -285,4 +285,104 @@ describe('GET /api/projects/:projectId/resource-profile', () => { }), ]) }) + + it('includes derived actual assignment weeks for named resources', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: 'proj-1', + ownerId: userId, + hoursPerDay: 8, + bufferWeeks: 0, + onboardingWeeks: 0, + weeklyDemandCache: { + 'Security|12': 3.6, + }, + resourceTypes: [ + { + id: 'rt-security', + name: 'Security', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-security', + name: 'Principal Consultant - Security', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + ], + epics: [ + { + id: 'epic-1', + name: 'Security', + order: 0, + isActive: true, + features: [ + { + id: 'feat-1', + name: 'Security Design', + order: 0, + isActive: true, + userStories: [ + { + id: 'story-1', + name: 'Threat model', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + hoursEffort: 28.8, + resourceTypeId: 'rt-security', + }, + ], + }, + ], + }, + ], + }, + ], + overheads: [], + timelineEntries: [ + { featureId: 'feat-1', startWeek: 12, durationWeeks: 1 }, + ], + storyTimelineEntries: [], + capacityPlans: [], + } as any) + + const res = await request(app) + .get('/api/projects/proj-1/resource-profile') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const securityRow = res.body.resourceRows.find((row: any) => row.resourceTypeId === 'rt-security') + expect(securityRow.namedResources).toEqual([ + expect.objectContaining({ + name: 'Principal Consultant - Security', + actualAllocatedDays: 3.6, + actualAllocationStartWeek: 12, + actualAllocationEndWeek: 12, + actualAllocatedWeeks: [ + expect.objectContaining({ + week: 12, + days: 3.6, + capacityDays: 5, + }), + ], + }), + ]) + }) }) diff --git a/server/src/test/resourceTypes.test.ts b/server/src/test/resourceTypes.test.ts new file mode 100644 index 00000000..02903613 --- /dev/null +++ b/server/src/test/resourceTypes.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import request from 'supertest' +import jwt from 'jsonwebtoken' +import { app } from '../index.js' +import { prisma } from '../lib/prisma.js' + +process.env.JWT_SECRET = 'test-secret' + +const userId = 'user-1' +const token = jwt.sign({ userId }, 'test-secret') +const authHeader = `Bearer ${token}` + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('resource type manual scheduling regression', () => { + it('exits CAPACITY_PLAN when count is manually updated through the resource type route', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.findFirst).mockResolvedValue({ + id: 'rt-1', + projectId: 'proj-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + } as never) + vi.mocked(prisma.resourceType.update).mockResolvedValue({ + id: 'rt-1', + count: 2, + allocationMode: 'TIMELINE', + } as never) + vi.mocked(prisma.namedResource.updateMany).mockResolvedValue({ count: 2 } as never) + + const res = await request(app) + .put('/api/projects/proj-1/resource-types/rt-1') + .set('Authorization', authHeader) + .send({ count: 2 }) + + expect(res.status).toBe(200) + expect(prisma.resourceType.update).toHaveBeenCalledWith({ + where: { id: 'rt-1' }, + data: { + count: 2, + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + }) + expect(prisma.namedResource.updateMany).toHaveBeenCalledWith({ + where: { + resourceTypeId: 'rt-1', + allocationMode: 'CAPACITY_PLAN', + }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + allocationPct: 100, + startWeek: null, + endWeek: null, + }, + }) + }) + + it('preserves explicit allocationMode edits on the resource type route', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.findFirst).mockResolvedValue({ + id: 'rt-1', + projectId: 'proj-1', + allocationMode: 'CAPACITY_PLAN', + } as never) + vi.mocked(prisma.resourceType.update).mockResolvedValue({ + id: 'rt-1', + count: 2, + allocationMode: 'FULL_PROJECT', + allocationPercent: 50, + } as never) + + const res = await request(app) + .put('/api/projects/proj-1/resource-types/rt-1') + .set('Authorization', authHeader) + .send({ count: 2, allocationMode: 'FULL_PROJECT', allocationPercent: 50 }) + + expect(res.status).toBe(200) + expect(prisma.resourceType.update).toHaveBeenCalledWith({ + where: { id: 'rt-1' }, + data: { + count: 2, + allocationMode: 'FULL_PROJECT', + allocationPercent: 50, + }, + }) + expect(prisma.namedResource.updateMany).not.toHaveBeenCalled() + }) + + it('exits CAPACITY_PLAN when a person is manually removed from Resource Profile', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.findFirst).mockResolvedValue({ + id: 'rt-1', + projectId: 'proj-1', + name: 'Developer', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + } as never) + vi.mocked(prisma.namedResource.findFirst).mockResolvedValue({ + id: 'nr-2', + resourceTypeId: 'rt-1', + } as never) + vi.mocked(prisma.namedResource.updateMany).mockResolvedValue({ count: 2 } as never) + vi.mocked(prisma.resourceType.update) + .mockResolvedValueOnce({ id: 'rt-1', allocationMode: 'TIMELINE' } as never) + .mockResolvedValueOnce({ id: 'rt-1', count: 1, allocationMode: 'TIMELINE' } as never) + vi.mocked(prisma.namedResource.delete).mockResolvedValue({} as never) + vi.mocked(prisma.namedResource.count).mockResolvedValue(1) + + const res = await request(app) + .delete('/api/projects/proj-1/resource-types/rt-1/named-resources/nr-2') + .set('Authorization', authHeader) + + expect(res.status).toBe(204) + expect(prisma.resourceType.update).toHaveBeenNthCalledWith(1, { + where: { id: 'rt-1' }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + }) + expect(prisma.namedResource.updateMany).toHaveBeenCalledWith({ + where: { + resourceTypeId: 'rt-1', + allocationMode: 'CAPACITY_PLAN', + }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + allocationPct: 100, + startWeek: null, + endWeek: null, + }, + }) + expect(prisma.resourceType.update).toHaveBeenNthCalledWith(2, { + where: { id: 'rt-1' }, + data: { count: 1 }, + }) + }) +}) diff --git a/server/src/test/sa-planner.test.ts b/server/src/test/sa-planner.test.ts index 0a2951b6..4ff9b1d9 100644 --- a/server/src/test/sa-planner.test.ts +++ b/server/src/test/sa-planner.test.ts @@ -54,6 +54,20 @@ function getFeatureWeeks(result: ReturnType, featureId: str return [...(result.weeklyAllocationsByFeature.get(featureId)?.keys() ?? [])].sort((a, b) => a - b) } +function getFeatureRtAllocations( + result: ReturnType, + featureId: string, + rtId: string, +): Array<{ week: number, days: number }> { + const byWeek = result.weeklyAllocationsByFeature.get(featureId) + if (!byWeek) return [] + + return [...byWeek.entries()] + .map(([week, byRt]) => ({ week, days: byRt.get(rtId) ?? 0 })) + .filter(item => item.days > 0) + .sort((a, b) => a.week - b.week) +} + describe('runSAPlanner weekly fractional staffing', () => { it('never over-allocates weekly RT capacity', () => { const input = makeInput() @@ -131,4 +145,193 @@ describe('runSAPlanner weekly fractional staffing', () => { expect(weeklyDemand[1]).toBeCloseTo(20, 5) expect(result.totalDeliveryWeeks).toBe(2) }) + + it('spreads a short secondary RT slice across the longer feature span', () => { + const input = makeInput() + input.resourceTypes = [ + { id: 'rt-1', name: 'Principal Consultant', count: 1, hoursPerDay: 8, namedResources: [] }, + { id: 'rt-2', name: 'Senior Engineer', count: 1, hoursPerDay: 8, namedResources: [] }, + ] + + input.epics[0].features = [ + { + id: 'long-multi-rt', + order: 0, + isActive: true as const, + timelineStartWeek: null, + userStories: [{ + id: 'long-multi-rt-story', + order: 0, + isActive: true as const, + tasks: [ + { + resourceTypeId: 'rt-1', + hoursEffort: 30 * 8, + durationDays: null, + resourceType: { id: 'rt-1', name: 'Principal Consultant', hoursPerDay: 8 }, + }, + { + resourceTypeId: 'rt-2', + hoursEffort: 6 * 8, + durationDays: null, + resourceType: { id: 'rt-2', name: 'Senior Engineer', hoursPerDay: 8 }, + }, + ], + }], + dependencies: [], + }, + { + id: 'competing-rt2-feature', + order: 1, + isActive: true as const, + timelineStartWeek: null, + userStories: [{ + id: 'competing-rt2-feature-story', + order: 0, + isActive: true as const, + tasks: [{ + resourceTypeId: 'rt-2', + hoursEffort: 30 * 8, + durationDays: null, + resourceType: { id: 'rt-2', name: 'Senior Engineer', hoursPerDay: 8 }, + }], + }], + dependencies: [], + }, + ] + + const result = runSAPlanner(input, { + targetDurationWeeks: 12, + maxParallelismPerFeature: 1, + }) + + const rt2Allocations = getFeatureRtAllocations(result, 'long-multi-rt', 'rt-2') + const firstTwoWeeksDays = rt2Allocations + .filter(item => item.week <= 1) + .reduce((total, item) => total + item.days, 0) + + expect(rt2Allocations).toHaveLength(5) + expect(rt2Allocations[0]?.week).toBe(0) + expect(rt2Allocations[4]?.week).toBe(4) + expect(firstTwoWeeksDays).toBeLessThan(3) + expect(rt2Allocations.every(item => item.days <= 1.200001)).toBe(true) + }) + + it('finishes long dependency chains under fractional capacity without horizon underrun', () => { + const input = makeInput() + input.resourceTypes = [ + { id: 'rt-1', name: 'Fractional A', count: 0.25, hoursPerDay: 8, namedResources: [] }, + { id: 'rt-2', name: 'Fractional B', count: 0.25, hoursPerDay: 8, namedResources: [] }, + ] + + const featureCount = 20 + input.epics[0].features = Array.from({ length: featureCount }, (_, index) => { + const id = `f-${index}` + const rtId = index % 2 === 0 ? 'rt-1' : 'rt-2' + return { + id, + order: index, + isActive: true as const, + timelineStartWeek: null, + userStories: [{ + id: `${id}-story`, + order: 0, + isActive: true as const, + tasks: [{ + resourceTypeId: rtId, + hoursEffort: 10 * 8, + durationDays: null, + resourceType: { id: rtId, name: rtId, hoursPerDay: 8 }, + }], + }], + dependencies: index === 0 ? [] : [{ featureId: id, dependsOnId: `f-${index - 1}` }], + } + }) + + const result = runSAPlanner(input, { + targetDurationWeeks: 24, + maxParallelismPerFeature: 2, + }) + + expect(result.totalDeliveryWeeks).toBeGreaterThan(112) + expect(result.featureStartWeeks.get(`f-${featureCount - 1}`)).toBeDefined() + }) + + it('avoids horizon underrun when feature caps and sequencing dominate duration', () => { + const input = makeInput() + input.resourceTypes = [ + { id: 'rt-1', name: 'Dev', count: 10, hoursPerDay: 8, namedResources: [] }, + ] + + const featureCount = 30 + input.epics[0].features = Array.from({ length: featureCount }, (_, index) => { + const id = `serial-${index}` + return { + id, + order: index, + isActive: true as const, + timelineStartWeek: null, + userStories: [{ + id: `${id}-story`, + order: 0, + isActive: true as const, + tasks: [{ + resourceTypeId: 'rt-1', + hoursEffort: 20 * 8, + durationDays: null, + resourceType: { id: 'rt-1', name: 'Dev', hoursPerDay: 8 }, + }], + }], + dependencies: index === 0 ? [] : [{ featureId: id, dependsOnId: `serial-${index - 1}` }], + } + }) + + const result = runSAPlanner(input, { + targetDurationWeeks: 12, + maxParallelismPerFeature: 1, + }) + + // Legacy sizing (aggregate RT demand only) would cap this case at ~90 weeks. + const legacyRoughDuration = (featureCount * 20) / (10 * 5) + const legacyMaxWeeks = Math.max( + 52, + Math.ceil(12 * 3), + Math.ceil(legacyRoughDuration * 4) + featureCount + 12, + ) + + expect(result.totalDeliveryWeeks).toBeGreaterThan(legacyMaxWeeks) + expect(result.featureStartWeeks.get(`serial-${featureCount - 1}`)).toBeDefined() + }) + + it('handles delayed named-resource availability when sizing planner horizon', () => { + const input = makeInput() + input.resourceTypes = [ + { + id: 'rt-1', + name: 'Dev', + count: 1, + hoursPerDay: 8, + namedResources: [{ + id: 'nr-1', + name: 'Late starter', + startWeek: 100, + endWeek: null, + allocationPct: 100, + allocationMode: 'FULL_PROJECT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }], + }, + ] + input.epics[0].features = [makeFeature('late-start-feature', 0, 5)] + + const result = runSAPlanner(input, { targetDurationWeeks: 12 }) + const weeklyDemand = result.weeklyDemandByResourceType.get('rt-1') ?? [] + + expect(result.featureStartWeeks.get('late-start-feature')).toBe(100) + expect(result.totalDeliveryWeeks).toBe(101) + expect(weeklyDemand.slice(0, 100).every(days => (days ?? 0) === 0)).toBe(true) + expect(weeklyDemand[100]).toBeCloseTo(5, 5) + }) }) diff --git a/server/src/test/squadPlan.test.ts b/server/src/test/squadPlan.test.ts index 487154c8..f7640983 100644 --- a/server/src/test/squadPlan.test.ts +++ b/server/src/test/squadPlan.test.ts @@ -376,4 +376,145 @@ describe('POST /api/projects/:projectId/squad-plan/apply', () => { data: { weeklyDemandCache: { 'Developer|0': 2 } }, }) }) + + it('replays applied reduced-period capacity into weeklyDemandCache', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject as never) + vi.mocked(prisma.resourceType.findMany) + .mockResolvedValueOnce([{ id: 'rt-dev' }] as never) + .mockResolvedValueOnce([ + { + id: 'rt-dev', + name: 'Developer', + count: 1, + hoursPerDay: 8, + allocationMode: 'CAPACITY_PLAN', + namedResources: [ + { + id: 'nr-dev-1', + name: 'Developer 1', + startWeek: 52, + endWeek: 60, + allocationPct: 100, + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + ], + }, + ] as never) + vi.mocked(prisma.epic.findMany).mockResolvedValue([ + { + id: 'epic-1', + name: 'Epic 1', + order: 0, + isActive: true, + featureMode: 'sequential', + scheduleMode: 'sequential', + timelineStartWeek: null, + features: [ + { + id: 'feature-1', + name: 'Feature 1', + order: 0, + isActive: true, + timelineStartWeek: null, + userStories: [ + { + id: 'story-1', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + resourceTypeId: 'rt-dev', + hoursEffort: 240, + durationDays: null, + resourceType: { id: 'rt-dev', name: 'Developer', hoursPerDay: 8 }, + }, + ], + dependencies: [], + }, + ], + dependencies: [], + }, + ], + }, + ] as never) + vi.mocked(prisma.timelineEntry.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.storyTimelineEntry.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.epicDependency.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.backlogSnapshot.create).mockResolvedValue({ id: 'snapshot-1' } as never) + vi.mocked(prisma.capacityPlan.updateMany).mockResolvedValue({ count: 0 } as never) + vi.mocked(prisma.capacityPlan.create).mockResolvedValue({ + id: 'plan-1', + projectId: 'proj-1', + isActive: true, + periods: [], + } as never) + vi.mocked(prisma.resourceType.update).mockResolvedValue({} as never) + vi.mocked(prisma.resourceType.updateMany).mockResolvedValue({ count: 0 } as never) + vi.mocked(prisma.namedResource.updateMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(prisma.namedResource.findMany) + .mockResolvedValueOnce([{ id: 'nr-dev-1' }, { id: 'nr-dev-2' }] as never) + .mockResolvedValueOnce([{ id: 'nr-dev-1' }, { id: 'nr-dev-2' }] as never) + vi.mocked(prisma.namedResource.update).mockResolvedValue({} as never) + vi.mocked(prisma.epic.update).mockResolvedValue({} as never) + vi.mocked(prisma.project.update).mockResolvedValue({ + ...mockProject, + weeklyDemandCache: {}, + } as never) + + const res = await request(app) + .post('/api/projects/proj-1/squad-plan/apply') + .set('Authorization', authHeader) + .send({ + name: 'Applied plan with taper', + targetWeeks: 8, + periodWeeks: 4, + maxDelta: 1, + setActive: true, + periods: [ + { + periodIndex: 0, + startWeek: 0, + endWeek: 4, + entries: [ + { + resourceTypeId: 'rt-dev', + headcount: 1, + demandFTE: 1, + utilisationPct: 100, + }, + ], + }, + { + periodIndex: 1, + startWeek: 4, + endWeek: 8, + entries: [ + { + resourceTypeId: 'rt-dev', + headcount: 0.5, + demandFTE: 0.5, + utilisationPct: 100, + }, + ], + }, + ], + levellingResult: { + epicStartWeeks: { 'epic-1': 0 }, + featureStartWeeks: { 'feature-1': 0 }, + totalDeliveryWeeks: 8, + peakUtilisationPct: 100, + }, + }) + + expect(res.status).toBe(201) + const projectUpdateArg = vi.mocked(prisma.project.update).mock.calls.at(-1)?.[0] + const weeklyDemandCache = projectUpdateArg?.data?.weeklyDemandCache as Record + expect(weeklyDemandCache['Developer|0']).toBeCloseTo(5, 6) + expect(weeklyDemandCache['Developer|4']).toBeCloseTo(2.5, 6) + expect(weeklyDemandCache['Developer|4']).toBeLessThan(5) + }) }) diff --git a/server/src/test/timeline.test.ts b/server/src/test/timeline.test.ts index db06f4ed..3e000b20 100644 --- a/server/src/test/timeline.test.ts +++ b/server/src/test/timeline.test.ts @@ -294,6 +294,332 @@ describe('GET /api/projects/:projectId/timeline', () => { ])) }) + it('derives actual named-resource assignment weeks from role demand without exceeding weekly capacity', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + ...mockProject, + weeklyDemandCache: { + 'Senior Engineer - Cloud & DevOps|56': 5.9, + }, + } as any) + vi.mocked(prisma.timelineEntry.findMany).mockResolvedValue([{ + id: 'entry-cloud', + projectId: 'proj-1', + featureId: 'feat-cloud', + startWeek: 56, + durationWeeks: 1, + isManual: false, + feature: { + id: 'feat-cloud', + name: 'Cloud Work', + order: 0, + isActive: true, + epic: { + id: 'epic-1', + name: 'Cloud', + order: 0, + isActive: true, + featureMode: 'SEQUENTIAL', + scheduleMode: 'AUTO', + timelineStartWeek: null, + }, + userStories: [], + }, + }] as any) + vi.mocked(prisma.resourceType.findMany).mockResolvedValue([{ + id: 'rt-cloud', + name: 'Senior Engineer - Cloud & DevOps', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + namedResources: [ + { + id: 'nr-cloud', + name: 'Taylor', + startWeek: null, + endWeek: null, + allocationPct: 100, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + ], + }] as any) + vi.mocked(prisma.capacityPlan.findFirst).mockResolvedValue(null as any) + + const res = await request(app) + .get('/api/projects/proj-1/timeline') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + expect(res.body.namedResources).toEqual(expect.arrayContaining([ + expect.objectContaining({ + resourceTypeName: 'Senior Engineer - Cloud & DevOps', + name: 'Taylor', + actualAllocatedDays: 5, + actualAllocationStartWeek: 56, + actualAllocationEndWeek: 56, + actualAllocatedWeeks: [ + expect.objectContaining({ + week: 56, + days: 5, + capacityDays: 5, + }), + ], + }), + ])) + }) + + it('backfills weeklyDemand beyond cached horizon from scheduled entries', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + ...mockProject, + weeklyDemandCache: { + 'Developer|0': 1.25, + }, + } as any) + vi.mocked(prisma.timelineEntry.findMany).mockResolvedValue([{ + id: 'entry-long-tail', + projectId: 'proj-1', + featureId: 'feat-long-tail', + startWeek: 60, + durationWeeks: 10, + isManual: false, + feature: { + id: 'feat-long-tail', + name: 'Long Tail Work', + order: 0, + isActive: true, + epic: { + id: 'epic-1', + name: 'Late Epic', + order: 0, + isActive: true, + featureMode: 'SEQUENTIAL', + scheduleMode: 'AUTO', + timelineStartWeek: null, + }, + userStories: [{ + isActive: true, + tasks: [{ + resourceTypeId: 'rt-1', + hoursEffort: 400, + durationDays: null, + resourceType: { id: 'rt-1', name: 'Developer', hoursPerDay: 8 }, + }], + }], + }, + }] as any) + vi.mocked(prisma.resourceType.findMany).mockResolvedValue([{ + id: 'rt-1', + name: 'Developer', + category: 'ENGINEERING', + count: 2, + hoursPerDay: 8, + allocationMode: 'EFFORT', + namedResources: [], + }] as any) + + const res = await request(app) + .get('/api/projects/proj-1/timeline') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const developerDemand = res.body.weeklyDemand.filter((row: any) => row.resourceTypeName === 'Developer') + const weeks = developerDemand.map((row: any) => row.week) + const uniqueKeys = new Set(developerDemand.map((row: any) => `${row.week}|${row.resourceTypeName}`)) + + expect(weeks).toContain(65) + expect(weeks).toContain(69) + expect(uniqueKeys.size).toBe(developerDemand.length) + }) + + it('does not backfill a missing interior cached week, but still backfills beyond the cached horizon', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + ...mockProject, + weeklyDemandCache: { + 'Developer|55': 2.5, + 'Developer|57': 2.5, + }, + } as any) + vi.mocked(prisma.timelineEntry.findMany).mockResolvedValue([{ + id: 'entry-cached-gap', + projectId: 'proj-1', + featureId: 'feat-cached-gap', + startWeek: 55, + durationWeeks: 5, + isManual: false, + feature: { + id: 'feat-cached-gap', + name: 'Cached Gap Work', + order: 0, + isActive: true, + epic: { + id: 'epic-1', + name: 'Delivery', + order: 0, + isActive: true, + featureMode: 'SEQUENTIAL', + scheduleMode: 'AUTO', + timelineStartWeek: null, + }, + userStories: [{ + isActive: true, + tasks: [{ + resourceTypeId: 'rt-1', + hoursEffort: 200, + durationDays: null, + resourceType: { id: 'rt-1', name: 'Developer', hoursPerDay: 8 }, + }], + }], + }, + }] as any) + vi.mocked(prisma.resourceType.findMany).mockResolvedValue([{ + id: 'rt-1', + name: 'Developer', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + namedResources: [], + }] as any) + + const res = await request(app) + .get('/api/projects/proj-1/timeline') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const developerDemand = res.body.weeklyDemand + .filter((row: any) => row.resourceTypeName === 'Developer') + .reduce((acc: Record, row: any) => ({ ...acc, [row.week]: row.demandDays }), {}) + + expect(developerDemand[55]).toBe(2.5) + expect(developerDemand[56]).toBeUndefined() + expect(developerDemand[57]).toBe(2.5) + expect(developerDemand[58]).toBe(5) + expect(developerDemand[59]).toBe(5) + }) + + it('does not backfill a cached role before the global cached horizon ends, but still backfills after it', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + ...mockProject, + weeklyDemandCache: { + 'Principal Consultant - Security|10': 2.5, + 'Principal Consultant - Security|11': 2.5, + 'Developer|10': 1.5, + 'Developer|14': 1.5, + }, + } as any) + vi.mocked(prisma.timelineEntry.findMany).mockResolvedValue([ + { + id: 'entry-security-tail', + projectId: 'proj-1', + featureId: 'feat-security-tail', + startWeek: 10, + durationWeeks: 7, + isManual: false, + feature: { + id: 'feat-security-tail', + name: 'Security Tail Work', + order: 0, + isActive: true, + epic: { + id: 'epic-1', + name: 'Security', + order: 0, + isActive: true, + featureMode: 'SEQUENTIAL', + scheduleMode: 'AUTO', + timelineStartWeek: null, + }, + userStories: [{ + isActive: true, + tasks: [{ + resourceTypeId: 'rt-security', + hoursEffort: 280, + durationDays: null, + resourceType: { id: 'rt-security', name: 'Principal Consultant - Security', hoursPerDay: 8 }, + }], + }], + }, + }, + { + id: 'entry-cache-anchor', + projectId: 'proj-1', + featureId: 'feat-cache-anchor', + startWeek: 10, + durationWeeks: 5, + isManual: false, + feature: { + id: 'feat-cache-anchor', + name: 'Developer Cache Anchor', + order: 1, + isActive: true, + epic: { + id: 'epic-1', + name: 'Platform', + order: 1, + isActive: true, + featureMode: 'SEQUENTIAL', + scheduleMode: 'AUTO', + timelineStartWeek: null, + }, + userStories: [{ + isActive: true, + tasks: [{ + resourceTypeId: 'rt-1', + hoursEffort: 200, + durationDays: null, + resourceType: { id: 'rt-1', name: 'Developer', hoursPerDay: 8 }, + }], + }], + }, + }, + ] as any) + vi.mocked(prisma.resourceType.findMany).mockResolvedValue([ + { + id: 'rt-security', + name: 'Principal Consultant - Security', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + namedResources: [], + }, + { + id: 'rt-1', + name: 'Developer', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + namedResources: [], + }, + ] as any) + vi.mocked(prisma.capacityPlan.findFirst).mockResolvedValue(null as any) + + const res = await request(app) + .get('/api/projects/proj-1/timeline') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const securityDemand = res.body.weeklyDemand + .filter((row: any) => row.resourceTypeName === 'Principal Consultant - Security') + .reduce((acc: Record, row: any) => ({ ...acc, [row.week]: row.demandDays }), {}) + + expect(securityDemand[10]).toBe(2.5) + expect(securityDemand[11]).toBe(2.5) + expect(securityDemand[12]).toBeUndefined() + expect(securityDemand[13]).toBeUndefined() + expect(securityDemand[14]).toBeUndefined() + expect(securityDemand[15]).toBe(5) + expect(securityDemand[16]).toBe(5) + }) + it('uses materialized CAPACITY_PLAN split capacity for parallel warnings', async () => { vi.mocked(prisma.project.findFirst).mockResolvedValue({ ...mockProject, From 3f4dc42916f9b35905618c4462872ac240758646 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Thu, 7 May 2026 13:45:46 +1000 Subject: [PATCH 02/11] fix(#233): address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- client/src/components/timeline/GanttBar.tsx | 5 +- client/src/components/timeline/GanttChart.tsx | 6 +- .../timeline/SquadPlannerDrawer.tsx | 101 ++++++++++++------ .../timeline/TimelineOptimiserDrawer.tsx | 66 ++++++++---- client/src/components/timeline/timelineUx.ts | 2 +- client/src/pages/TimelinePage.tsx | 30 ++++-- client/src/test/timelineDrawerState.test.tsx | 94 ++++++++++++++++ client/src/test/timelineUx.test.ts | 12 +-- server/src/routes/optimiser.ts | 98 +++++++++++++++-- server/src/routes/timeline.ts | 5 +- server/src/test/optimiser.test.ts | 59 ++++++++++ 11 files changed, 392 insertions(+), 86 deletions(-) create mode 100644 client/src/test/timelineDrawerState.test.tsx diff --git a/client/src/components/timeline/GanttBar.tsx b/client/src/components/timeline/GanttBar.tsx index 2cf8eb5a..281cf589 100644 --- a/client/src/components/timeline/GanttBar.tsx +++ b/client/src/components/timeline/GanttBar.tsx @@ -109,10 +109,11 @@ export default function GanttBar({ const barColor = entry.timelineColour ?? colour.hex const isDragging = dragging?.type === 'feature' && dragging.id === entry.featureId const effectiveStart = isDragging ? dragging!.currentStart : entry.startWeek + const effectiveEnd = effectiveStart + entry.durationWeeks const barW = Math.max(entry.durationWeeks * colW, 4) const isOverAllocated = weeklyDemand.some(d => - d.week >= entry.startWeek && - d.week < entry.startWeek + entry.durationWeeks && + d.week >= effectiveStart && + d.week < effectiveEnd && d.demandDays > d.capacityDays + 0.01, ) const tooltipContent = buildFeatureTooltip(entry) diff --git a/client/src/components/timeline/GanttChart.tsx b/client/src/components/timeline/GanttChart.tsx index dcf3ede4..e6b8d8c5 100644 --- a/client/src/components/timeline/GanttChart.tsx +++ b/client/src/components/timeline/GanttChart.tsx @@ -349,6 +349,9 @@ export default function GanttChart({ {/* Right SVG area — horizontally scrollable */}
+ {/* Background fill */} + + - {/* Background fill */} - - {/* Onboarding zone */} {weekOffset > 0 && ( diff --git a/client/src/components/timeline/SquadPlannerDrawer.tsx b/client/src/components/timeline/SquadPlannerDrawer.tsx index 81c9cd74..9da03e2a 100644 --- a/client/src/components/timeline/SquadPlannerDrawer.tsx +++ b/client/src/components/timeline/SquadPlannerDrawer.tsx @@ -180,6 +180,25 @@ function loadPersistedSettings(projectId: string): PersistedPlannerSettings | nu } } +function buildResourceTypesKey(resourceTypes: Props['resourceTypes']) { + return resourceTypes.map(rt => `${rt.id}:${rt.name}:${rt.count}`).join('|') +} + +function buildSeedSettingsKey(seedSettings?: SquadPlannerSeedSettings | null) { + if (!seedSettings) return '' + + const minFloor = Object.entries(seedSettings.minFloor) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([resourceTypeId, value]) => `${resourceTypeId}:${value}`) + .join('|') + const maxCap = Object.entries(seedSettings.maxCap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([resourceTypeId, value]) => `${resourceTypeId}:${value}`) + .join('|') + + return `${seedSettings.seededResourceTypeIds.join('|')}::${minFloor}::${maxCap}` +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -209,43 +228,13 @@ export default function SquadPlannerDrawer({ const qc = useQueryClient() const skipNextPersistRef = useRef(false) + const wasOpenRef = useRef(false) + const lastRestoreSeedRef = useRef(null) + const restoreSeed = `${projectId}::${buildResourceTypesKey(resourceTypes)}::${buildSeedSettingsKey(seedSettings)}` const effectiveMonths = customMonths ? Number(customMonths) : targetMonths const targetWeeks = Math.round(effectiveMonths * 4.33) - // ── restore state when drawer opens ────────────────────────────────────── - useEffect(() => { - if (!open) return - - const saved = loadPersistedSettings(projectId) - const nextMinFloor = mergePerResourceSettings(saved?.minFloor, resourceTypes, () => 0) - const nextMaxCap = mergePerResourceSettings(saved?.maxCap, resourceTypes, () => undefined) - - skipNextPersistRef.current = true - - setTargetMonths(saved?.targetMonths ?? 18) - setCustomMonths(saved?.customMonths ?? '') - setPeriodWeeks(saved?.periodWeeks === 4 ? 4 : 13) - setSmoothingMode( - saved?.smoothingMode === 'tight' || saved?.smoothingMode === 'exact' ? saved.smoothingMode : 'smooth', - ) - setMaxDelta(saved?.maxDelta ?? 1) - setBufferPct(saved?.bufferPct ?? 20) - setMaxParallelism(saved?.maxParallelism ?? 2) - setMaxConcurrentEpics(saved?.maxConcurrentEpics ?? 6) - setMinFloor(seedSettings ? { ...nextMinFloor, ...seedSettings.minFloor } : nextMinFloor) - setMaxCap(seedSettings ? { ...nextMaxCap, ...seedSettings.maxCap } : nextMaxCap) - setShowAllResourceTypes(false) - setError(null) - setSeedBanner( - seedSettings && seedSettings.seededResourceTypeIds.length > 0 - ? 'Seeded from Starting Team Finder. Adjust these bounds to refine the candidate squad.' - : null, - ) - generate.reset() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, projectId, resourceTypes, seedSettings]) - useEffect(() => { setMinFloor(prev => mergePerResourceSettings(prev, resourceTypes, rtId => prev[rtId] ?? 0)) setMaxCap(prev => mergePerResourceSettings(prev, resourceTypes, rtId => prev[rtId])) @@ -335,6 +324,52 @@ export default function SquadPlannerDrawer({ onSuccess: () => setError(null), }) + const restoreSettings = useCallback(() => { + const saved = loadPersistedSettings(projectId) + const nextMinFloor = mergePerResourceSettings(saved?.minFloor, resourceTypes, () => 0) + const nextMaxCap = mergePerResourceSettings(saved?.maxCap, resourceTypes, () => undefined) + + skipNextPersistRef.current = true + + setTargetMonths(saved?.targetMonths ?? 18) + setCustomMonths(saved?.customMonths ?? '') + setPeriodWeeks(saved?.periodWeeks === 4 ? 4 : 13) + setSmoothingMode( + saved?.smoothingMode === 'tight' || saved?.smoothingMode === 'exact' ? saved.smoothingMode : 'smooth', + ) + setMaxDelta(saved?.maxDelta ?? 1) + setBufferPct(saved?.bufferPct ?? 20) + setMaxParallelism(saved?.maxParallelism ?? 2) + setMaxConcurrentEpics(saved?.maxConcurrentEpics ?? 6) + setMinFloor(seedSettings ? { ...nextMinFloor, ...seedSettings.minFloor } : nextMinFloor) + setMaxCap(seedSettings ? { ...nextMaxCap, ...seedSettings.maxCap } : nextMaxCap) + setShowAllResourceTypes(false) + setError(null) + setSeedBanner( + seedSettings && seedSettings.seededResourceTypeIds.length > 0 + ? 'Seeded from Starting Team Finder. Adjust these bounds to refine the candidate squad.' + : null, + ) + generate.reset() + }, [generate, projectId, resourceTypes, seedSettings]) + + // ── restore state when drawer opens ────────────────────────────────────── + useEffect(() => { + const opened = open && !wasOpenRef.current + const restoreSeedChanged = open && wasOpenRef.current && lastRestoreSeedRef.current !== restoreSeed + + if (opened || restoreSeedChanged) { + restoreSettings() + lastRestoreSeedRef.current = restoreSeed + } + + if (!open) { + lastRestoreSeedRef.current = restoreSeed + } + + wasOpenRef.current = open + }, [open, restoreSeed, restoreSettings]) + // ── apply mutation ────────────────────────────────────────────────────── const apply = useMutation({ mutationFn: (plan: CapacityPlanResult) => diff --git a/client/src/components/timeline/TimelineOptimiserDrawer.tsx b/client/src/components/timeline/TimelineOptimiserDrawer.tsx index 4a8495bd..4aaee52a 100644 --- a/client/src/components/timeline/TimelineOptimiserDrawer.tsx +++ b/client/src/components/timeline/TimelineOptimiserDrawer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useMutation } from '@tanstack/react-query' import { runOptimiser, @@ -35,6 +35,32 @@ interface Props { type Mode = 'speed' | 'utilisation' | 'balanced' +function buildResourceTypesKey(resourceTypes: Props['resourceTypes']) { + return resourceTypes.map(rt => `${rt.id}:${rt.name}:${rt.count}`).join('|') +} + +function buildIdListKey(ids?: string[]) { + return (ids ?? []).join('|') +} + +function buildDefaultCountRanges( + resourceTypes: Props['resourceTypes'], + fallbackPlannedResourceTypeIds?: string[], +) { + const defaultVisibility = getPlannerResourceTypeVisibility( + resourceTypes, + fallbackPlannedResourceTypeIds, + false, + ) + const ranges = new Map() + + for (const rt of defaultVisibility.visibleResourceTypes) { + ranges.set(rt.id, getStartingTeamFinderDefaultRange(rt.count)) + } + + return ranges +} + const MODE_LABELS: Record = { speed: 'Speed', utilisation: 'Utilisation', @@ -349,19 +375,11 @@ export default function TimelineOptimiserDrawer({ const [lastResult, setLastResult] = useState(null) const [runError, setRunError] = useState(null) const [showAllResourceTypes, setShowAllResourceTypes] = useState(false) + const wasOpenRef = useRef(false) + const lastRestoreSeedRef = useRef(null) + const restoreSeed = `${projectId}::${buildResourceTypesKey(resourceTypes)}::${buildIdListKey(fallbackPlannedResourceTypeIds)}` const resetSettings = useCallback(() => { - const defaultVisibility = getPlannerResourceTypeVisibility( - resourceTypes, - fallbackPlannedResourceTypeIds, - false, - ) - const ranges = new Map() - - for (const rt of defaultVisibility.visibleResourceTypes) { - ranges.set(rt.id, getStartingTeamFinderDefaultRange(rt.count)) - } - setMode('balanced') setAllowRampUp(false) setMaxBudget('') @@ -371,13 +389,25 @@ export default function TimelineOptimiserDrawer({ setRunError(null) setLastResult(null) setShowAllResourceTypes(false) - setCountRanges(ranges) + setCountRanges(buildDefaultCountRanges(resourceTypes, fallbackPlannedResourceTypeIds)) }, [fallbackPlannedResourceTypeIds, resourceTypes]) // ── initialise / reset when drawer opens ───────────────────────────────── useEffect(() => { - if (open) resetSettings() - }, [open, resetSettings]) + const opened = open && !wasOpenRef.current + const restoreSeedChanged = open && wasOpenRef.current && lastRestoreSeedRef.current !== restoreSeed + + if (opened || restoreSeedChanged) { + resetSettings() + lastRestoreSeedRef.current = restoreSeed + } + + if (!open) { + lastRestoreSeedRef.current = restoreSeed + } + + wasOpenRef.current = open + }, [open, resetSettings, restoreSeed]) // ── ESC to close ────────────────────────────────────────────────────────── const handleKeyDown = useCallback( @@ -479,7 +509,7 @@ export default function TimelineOptimiserDrawer({ // ── count range helpers ─────────────────────────────────────────────────── function setRange(rtId: string, field: 'min' | 'max', raw: string) { const v = parseInt(raw, 10) - if (isNaN(v) || v < 0) return + if (isNaN(v) || v < 1) return setCountRanges(prev => { const next = new Map(prev) const resourceType = resourceTypes.find(rt => rt.id === rtId) @@ -580,7 +610,7 @@ export default function TimelineOptimiserDrawer({

Defaults are intentionally broad to surface candidate squads for Squad Planner: - start at 0, then search up to the larger of current + 4 or 2× current, capped at 12. + start at 1, then search up to the larger of current + 4 or 2× current, capped at 12.

{visibleResourceTypes.map(rt => { @@ -594,7 +624,7 @@ export default function TimelineOptimiserDrawer({ setRange(rt.id, 'min', e.target.value)} className="w-14 border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-lab3-blue" diff --git a/client/src/components/timeline/timelineUx.ts b/client/src/components/timeline/timelineUx.ts index d5cbf119..10ba5fc7 100644 --- a/client/src/components/timeline/timelineUx.ts +++ b/client/src/components/timeline/timelineUx.ts @@ -135,7 +135,7 @@ export function getStartingTeamFinderDefaultRange(currentCount: number): Startin const safeCurrentCount = Number.isFinite(currentCount) ? Math.max(0, Math.floor(currentCount)) : 0 return { - min: 0, + min: 1, max: Math.min(12, Math.max(6, safeCurrentCount + 4, Math.ceil(safeCurrentCount * 2))), } } diff --git a/client/src/pages/TimelinePage.tsx b/client/src/pages/TimelinePage.tsx index eb948a38..a3f07e13 100644 --- a/client/src/pages/TimelinePage.tsx +++ b/client/src/pages/TimelinePage.tsx @@ -105,7 +105,7 @@ function NamedResourcesPanel({ return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)) }, [namedResources]) - const projectEndWeek = totalWeeks - 1 + const scheduleEndWeek = Math.max(totalWeeks - weekOffset - 1, 0) return (
@@ -179,11 +179,18 @@ function NamedResourcesPanel({
{/* Person bars */} {people.map((nr, i) => { - const start = nr.startWeek ?? 0 - const end = nr.endWeek ?? projectEndWeek + const start = Math.min(Math.max(nr.startWeek ?? 0, 0), scheduleEndWeek) + const end = Math.min(Math.max(nr.endWeek ?? scheduleEndWeek, start), scheduleEndWeek) const colour = RESOURCE_COLOURS[(colourIdx++) % RESOURCE_COLOURS.length] - const actualAllocatedWeeks = nr.actualAllocatedWeeks ?? [] - const actualAllocationSegments = nr.actualAllocationSegments ?? [] + const actualAllocatedWeeks = (nr.actualAllocatedWeeks ?? []) + .filter(allocation => allocation.week >= 0 && allocation.week <= scheduleEndWeek) + const actualAllocationSegments = (nr.actualAllocationSegments ?? []) + .map(segment => ({ + ...segment, + startWeek: Math.min(Math.max(segment.startWeek, 0), scheduleEndWeek), + endWeek: Math.min(Math.max(segment.endWeek, 0), scheduleEndWeek), + })) + .filter(segment => segment.endWeek >= segment.startWeek) const rtDemand = demandByRt.get(rtName) return (
({ - id: rt.id, - name: rt.name, - count: rt.count, - })) + const resourceTypesForOptimiser = useMemo( + () => (resourceTypes ?? []).map(rt => ({ + id: rt.id, + name: rt.name, + count: rt.count, + })), + [resourceTypes], + ) const scheduleTimeline = useMutation({ mutationFn: (body: { startDate?: string; resourceLevel?: boolean }) => diff --git a/client/src/test/timelineDrawerState.test.tsx b/client/src/test/timelineDrawerState.test.tsx new file mode 100644 index 00000000..eea3436d --- /dev/null +++ b/client/src/test/timelineDrawerState.test.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import TimelineOptimiserDrawer from '@/components/timeline/TimelineOptimiserDrawer' +import SquadPlannerDrawer from '@/components/timeline/SquadPlannerDrawer' + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) +} + +function renderWithClient(ui: React.ReactElement, client = createQueryClient()) { + return { + client, + ...render( + + {ui} + , + ), + } +} + +describe('timeline drawer state restoration', () => { + it('keeps Starting Team Finder edits when the parent rerenders with the same resource values', () => { + const onClose = vi.fn() + const onApplied = vi.fn() + const onRefineScenario = vi.fn() + const { rerender, client } = renderWithClient( + , + ) + + const [minInput] = screen.getAllByRole('spinbutton') + fireEvent.change(minInput, { target: { value: '3' } }) + expect(screen.getAllByRole('spinbutton')[0]).toHaveValue(3) + + rerender( + + + , + ) + + expect(screen.getAllByRole('spinbutton')[0]).toHaveValue(3) + }) + + it('keeps Squad Planner edits when the parent rerenders with the same resource values', () => { + const onClose = vi.fn() + const { rerender, client } = renderWithClient( + , + ) + + const minFloorInput = screen.getByTitle('Min headcount') + fireEvent.change(minFloorInput, { target: { value: '2' } }) + expect(screen.getByTitle('Min headcount')).toHaveValue(2) + + rerender( + + + , + ) + + expect(screen.getByTitle('Min headcount')).toHaveValue(2) + }) +}) diff --git a/client/src/test/timelineUx.test.ts b/client/src/test/timelineUx.test.ts index 758d97a6..c7cc1f3b 100644 --- a/client/src/test/timelineUx.test.ts +++ b/client/src/test/timelineUx.test.ts @@ -75,15 +75,15 @@ describe('getPlannerResourceTypeVisibility', () => { }) describe('getStartingTeamFinderDefaultRange', () => { - it('starts at zero and searches broadly around the current count', () => { - expect(getStartingTeamFinderDefaultRange(1)).toEqual({ min: 0, max: 6 }) - expect(getStartingTeamFinderDefaultRange(3)).toEqual({ min: 0, max: 7 }) - expect(getStartingTeamFinderDefaultRange(5)).toEqual({ min: 0, max: 10 }) + it('starts at one and searches broadly around the current count', () => { + expect(getStartingTeamFinderDefaultRange(1)).toEqual({ min: 1, max: 6 }) + expect(getStartingTeamFinderDefaultRange(3)).toEqual({ min: 1, max: 7 }) + expect(getStartingTeamFinderDefaultRange(5)).toEqual({ min: 1, max: 10 }) }) it('caps the search range at twelve people', () => { - expect(getStartingTeamFinderDefaultRange(8)).toEqual({ min: 0, max: 12 }) - expect(getStartingTeamFinderDefaultRange(20)).toEqual({ min: 0, max: 12 }) + expect(getStartingTeamFinderDefaultRange(8)).toEqual({ min: 1, max: 12 }) + expect(getStartingTeamFinderDefaultRange(20)).toEqual({ min: 1, max: 12 }) }) }) diff --git a/server/src/routes/optimiser.ts b/server/src/routes/optimiser.ts index 787e9cd9..2ca6af5e 100644 --- a/server/src/routes/optimiser.ts +++ b/server/src/routes/optimiser.ts @@ -29,6 +29,18 @@ import { type OptimiserCandidate, } from '../lib/optimiser.js' +interface ApplyCandidateResourceType { + resourceTypeId: string + count: number + suggestedStartWeek: number +} + +interface RequestedCountRange { + resourceTypeId: string + min: number + max: number +} + const router = Router({ mergeParams: true }) router.use(authenticate) @@ -96,6 +108,52 @@ async function loadSchedulerInput(projectId: string, hoursPerDay: number): Promi } } +function buildDefaultCountRanges( + resourceTypes: SchedulerResourceType[], +): RequestedCountRange[] { + return resourceTypes.map(rt => ({ + resourceTypeId: rt.id, + min: Math.max(1, rt.count - 2), + max: Math.min(6, rt.count + 2), + })) +} + +function sanitiseCountRanges( + requestedRanges: RequestedCountRange[] | undefined, + resourceTypes: SchedulerResourceType[], +): RequestedCountRange[] | null { + if (!requestedRanges) { + return buildDefaultCountRanges(resourceTypes) + } + + const validResourceTypeIds = new Set(resourceTypes.map(rt => rt.id)) + const seenIds = new Set() + const sanitised: RequestedCountRange[] = [] + + for (const range of requestedRanges) { + if ( + typeof range?.resourceTypeId !== 'string' + || seenIds.has(range.resourceTypeId) + || !validResourceTypeIds.has(range.resourceTypeId) + || !Number.isInteger(range.min) + || !Number.isInteger(range.max) + || range.min < 1 + || range.max < range.min + ) { + return null + } + + seenIds.add(range.resourceTypeId) + sanitised.push({ + resourceTypeId: range.resourceTypeId, + min: range.min, + max: range.max, + }) + } + + return sanitised +} + // ───────────────────────────────────────────────────────────────────────────── // POST /api/projects/:projectId/optimise/apply // Register BEFORE the root POST to avoid path ambiguity. @@ -108,7 +166,7 @@ router.post('/apply', asyncHandler(async (req: AuthRequest, res: Response) => { // Expected body: { resourceTypes: [{ resourceTypeId, count, suggestedStartWeek }], staggerEpics?: boolean } const { resourceTypes: candidateRTs, staggerEpics } = req.body as { - resourceTypes: Array<{ resourceTypeId: string; count: number; suggestedStartWeek: number }> + resourceTypes: ApplyCandidateResourceType[] staggerEpics?: boolean } if (!Array.isArray(candidateRTs) || candidateRTs.length === 0) { @@ -125,6 +183,23 @@ router.post('/apply', asyncHandler(async (req: AuthRequest, res: Response) => { res.status(400).json({ error: 'Invalid resourceTypes element' }); return } + const candidateIds = candidateRTs.map(rt => rt.resourceTypeId) + if (new Set(candidateIds).size !== candidateIds.length) { + res.status(400).json({ error: 'Duplicate resourceTypeId in resourceTypes array' }); return + } + + const projectResourceTypes = await prisma.resourceType.findMany({ + where: { + projectId, + id: { in: candidateIds }, + }, + select: { id: true }, + }) + + if (projectResourceTypes.length !== candidateIds.length) { + res.status(400).json({ error: 'All candidate resource types must belong to this project' }); return + } + // ── 1. Create pre-apply snapshot for undo support ───────────────────────── const snapshotData = await buildSnapshot(projectId) const dateStr = new Date().toISOString().slice(0, 10) @@ -150,7 +225,10 @@ router.post('/apply', asyncHandler(async (req: AuthRequest, res: Response) => { await prisma.$transaction(async tx => { // 3a. Update ResourceType counts for (const [rtId, count] of countMap) { - await tx.resourceType.update({ where: { id: rtId }, data: { count } }) + await tx.resourceType.updateMany({ + where: { id: rtId, projectId }, + data: { count }, + }) } // 3b. Update NamedResource.startWeek for ramp-up (only when > 0 to avoid @@ -158,7 +236,10 @@ router.post('/apply', asyncHandler(async (req: AuthRequest, res: Response) => { for (const [rtId, startWeek] of startWeekMap) { if (startWeek > 0) { await tx.namedResource.updateMany({ - where: { resourceTypeId: rtId }, + where: { + resourceTypeId: rtId, + resourceType: { projectId }, + }, data: { startWeek }, }) } @@ -324,13 +405,10 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { const schedulerInput = await loadSchedulerInput(projectId, project.hoursPerDay) // ── 3. Build countRanges (from request or sensible defaults: current ± 2, min 1, max 6) ── - const countRanges: Array<{ resourceTypeId: string; min: number; max: number }> = - body.constraints?.countRanges ?? - schedulerInput.resourceTypes.map(rt => ({ - resourceTypeId: rt.id, - min: Math.max(1, rt.count - 2), - max: Math.min(6, rt.count + 2), - })) + const countRanges = sanitiseCountRanges(body.constraints?.countRanges, schedulerInput.resourceTypes) + if (!countRanges) { + res.status(400).json({ error: 'Invalid constraints.countRanges' }); return + } // ── 4. Build day rates ──────────────────────────────────────────────────── // Phase 3: use ResourceType.dayRate directly (each RT stores its own rate). diff --git a/server/src/routes/timeline.ts b/server/src/routes/timeline.ts index bd780d7d..4ef479c5 100644 --- a/server/src/routes/timeline.ts +++ b/server/src/routes/timeline.ts @@ -546,9 +546,8 @@ router.post('/schedule', asyncHandler(async (req: AuthRequest, res: Response) => include: { story: { select: { name: true, featureId: true } } }, }) const allFeatureIds = entries.map(e => e.featureId) - const [featureDependencies, epicDependenciesForResponse, storyDependencies] = await Promise.all([ + const [featureDependencies, storyDependencies] = await Promise.all([ prisma.featureDependency.findMany({ where: { featureId: { in: allFeatureIds } }, select: { featureId: true, dependsOnId: true } }), - prisma.epicDependency.findMany({ where: { epic: { projectId: project.id } }, select: { epicId: true, dependsOnId: true } }), prisma.storyDependency.findMany({ where: { storyId: { in: storyTimelineEntries.map(e => e.storyId) } }, select: { storyId: true, dependsOnId: true } }), ]) @@ -570,7 +569,7 @@ router.post('/schedule', asyncHandler(async (req: AuthRequest, res: Response) => warningResourceTypes, ) - res.json(buildResponse(project, entries, parallelWarnings, mappedStoryEntries, featureDependencies, storyDependencies, epicDependenciesForResponse, resourceTypes, weeklyConsumptionMap, capacityPlanByRt)) + res.json(buildResponse(project, entries, parallelWarnings, mappedStoryEntries, featureDependencies, storyDependencies, epicDeps, resourceTypes, weeklyConsumptionMap, capacityPlanByRt)) })) // PUT /api/projects/:projectId/timeline/stories/:storyId — manual story timeline override diff --git a/server/src/test/optimiser.test.ts b/server/src/test/optimiser.test.ts index 326387f4..8f03ad05 100644 --- a/server/src/test/optimiser.test.ts +++ b/server/src/test/optimiser.test.ts @@ -955,6 +955,65 @@ describe('POST /api/projects/:projectId/optimise/apply — element-level validat expect(res.status).toBe(400) expect(res.body.error).toBe('Invalid resourceTypes element') }) + + it('returns 400 before snapshotting when a candidate resource type is outside the project', async () => { + vi.mocked(prisma.resourceType.findMany).mockResolvedValue([{ id: 'rt-1' }] as never) + + const res = await request(app) + .post(`/api/projects/${projectId}/optimise/apply`) + .set('Authorization', authHeader) + .send({ + resourceTypes: [ + { resourceTypeId: 'rt-1', count: 2, suggestedStartWeek: 0 }, + { resourceTypeId: 'rt-foreign', count: 1, suggestedStartWeek: 2 }, + ], + }) + + expect(res.status).toBe(400) + expect(res.body.error).toBe('All candidate resource types must belong to this project') + expect(prisma.backlogSnapshot.create).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/projects/:projectId/optimise — count range validation', () => { + beforeEach(() => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject as never) + vi.mocked(prisma.epic.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.timelineEntry.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.storyTimelineEntry.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.epicDependency.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.resourceType.findMany).mockResolvedValue([ + { id: 'rt-1', name: 'Developer', count: 2, hoursPerDay: 8, namedResources: [] }, + ] as never) + }) + + it('returns 400 when a count range min is zero', async () => { + const res = await request(app) + .post(`/api/projects/${projectId}/optimise`) + .set('Authorization', authHeader) + .send({ + constraints: { + countRanges: [{ resourceTypeId: 'rt-1', min: 0, max: 4 }], + }, + }) + + expect(res.status).toBe(400) + expect(res.body.error).toBe('Invalid constraints.countRanges') + }) + + it('returns 400 when a count range max is below min', async () => { + const res = await request(app) + .post(`/api/projects/${projectId}/optimise`) + .set('Authorization', authHeader) + .send({ + constraints: { + countRanges: [{ resourceTypeId: 'rt-1', min: 3, max: 2 }], + }, + }) + + expect(res.status).toBe(400) + expect(res.body.error).toBe('Invalid constraints.countRanges') + }) }) // ───────────────────────────────────────────────────────────────────────────── From 67e77eb905e062935ef303deccbcd7b28d72772a Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Thu, 7 May 2026 14:03:38 +1000 Subject: [PATCH 03/11] test(#233): fix timeline e2e selectors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/TESTS.md | 18 +++++----- e2e/tests/gantt.spec.ts | 14 ++++---- e2e/tests/helpers.ts | 22 +++++++++++- e2e/tests/timeline.spec.ts | 73 ++++++++++++++++---------------------- 4 files changed, 68 insertions(+), 59 deletions(-) diff --git a/e2e/TESTS.md b/e2e/TESTS.md index fe771383..4af33eef 100644 --- a/e2e/TESTS.md +++ b/e2e/TESTS.md @@ -113,34 +113,34 @@ API-level tests using the `request` fixture. No browser UI involved. | Test | Description | |------|-------------| | start date persists after navigation (bug #44) | Sets a start date, navigates away, returns — date is still present | -| auto-schedule shows projected end date | Create project with epic+feature, run Auto-schedule, assert "Projected end:" appears | +| quick schedule shows projected end date | Create project with epic+feature, run Quick schedule, assert "Projected end:" appears | | sequential/parallel toggle is visible on epic rows | After scheduling, the mode-toggle button is rendered on every epic header row in the Gantt | | feature dependency section visible in inline edit panel | Clicking a feature label opens the inline panel which contains the "Depends on" section and the add-dependency select | -#### `Scenario Finder drawer — open and close` describe block (1 test — Phase 4, issue #233) +#### `Starting Team Finder drawer — open and close` describe block (1 test — Phase 4, issue #233) | Test | Description | |------|-------------| -| open and close the drawer | Navigates to a Timeline page, clicks `🔧 Scenario Finder`, asserts the drawer dialog with accessible name and heading "Scenario Finder" is visible, clicks the Close (×) button, asserts drawer is removed from the DOM | +| open and close the drawer | Navigates to a Timeline page, clicks `🔧 Starting Team Finder`, asserts the drawer dialog with accessible name and heading "Starting Team Finder" is visible, clicks the Close (×) button, asserts drawer is removed from the DOM | -#### `Scenario Finder drawer — with resources` describe block (2 tests — Phase 4, issue #233) +#### `Starting Team Finder drawer — with resources` describe block (2 tests — Phase 4, issue #233) -`beforeEach` seeds a project with Developer + Tech Lead tasks via CSV import, navigates to Timeline, and runs Auto-schedule. Each test has a 90 s timeout. +`beforeEach` seeds a project with Developer + Tech Lead tasks via CSV import, navigates to Timeline, and runs Quick schedule. Each test has a 90 s timeout. | Test | Description | |------|-------------| -| run optimiser and see results | Opens drawer, clicks Run optimiser, waits up to 30 s for the search-stats footer (`Evaluated X scenarios in Ys`), asserts the Baseline card ("Current configuration") and at least one candidate card with an Apply button are visible | -| apply button is present on candidate cards, dialog is dismissed without mutation | Runs the optimiser, asserts every candidate card has an Apply button, clicks the first Apply, dismisses the browser `confirm()` dialog, and asserts the drawer remains open (no snapshot was created) | +| run optimiser and see results | Opens drawer, clicks `Find starting teams`, waits up to 30 s for the search-stats footer (`Evaluated X team options in Ys`), asserts the baseline card ("Current starting point") and at least one candidate card with an `Apply directly` button are visible | +| apply button is present on candidate cards, dialog is dismissed without mutation | Runs the finder, asserts every candidate card has an `Apply directly` button, clicks the first one, dismisses the browser `confirm()` dialog, and asserts the drawer remains open (no snapshot was created) | --- ### `gantt.spec.ts` — Gantt Chart (4 tests) -Selectors target the SVG-based Gantt introduced after the CSS-grid rewrite. Each test calls `setupTimeline()` which logs in, creates a project with 1 epic + 1 feature, navigates to the Timeline page, fills the start date, runs Auto-schedule, and waits for the "X features scheduled" footer. +Selectors target the SVG-based Gantt introduced after the CSS-grid rewrite. Each test calls `setupTimeline()` which logs in, creates a project with 1 epic + 1 feature, navigates to the Timeline page, fills the start date, runs Quick schedule, and waits for the "X features scheduled" footer. | Test | Description | |------|-------------| -| auto-schedule renders feature bars in the Gantt grid | After auto-schedule the SVG contains at least one `` element (feature bar) | +| quick schedule renders feature bars in the Gantt grid | After Quick schedule the SVG contains at least one `` element (feature bar) | | epic feature-mode button toggles between sequential and parallel | Clicks the button with `aria-label="sequential"`, asserts it switches to `aria-label="parallel"` | | clicking a feature bar opens the inline edit panel | Clicks `[title="{featureName}"]` (a ``), asserts Start week + duration inputs appear | | saving a manual start week shows the ✏ override indicator | Sets start week to 2, saves, asserts the "↺ Reset to auto" button appears (only rendered when `isManual=true`) | diff --git a/e2e/tests/gantt.spec.ts b/e2e/tests/gantt.spec.ts index 83fa9ccc..2a85f785 100644 --- a/e2e/tests/gantt.spec.ts +++ b/e2e/tests/gantt.spec.ts @@ -6,13 +6,13 @@ * (class "h-6 cursor-pointer") positioned via CSS grid-column. * * Tests cover: - * 1. Auto-schedule populates the Gantt grid with feature bars. + * 1. Quick schedule populates the Gantt grid with feature bars. * 2. The epic feature-mode button toggles sequential ↔ parallel. * 3. Clicking a feature bar (or label) opens the inline edit panel. * 4. Saving a manual start week via inline edit marks the bar with ✏. */ import { test, expect, type Page } from '@playwright/test' -import { login, createProject } from './helpers' +import { login, createProject, quickSchedule } from './helpers' // --------------------------------------------------------------------------- // Shared setup helper @@ -20,7 +20,7 @@ import { login, createProject } from './helpers' /** * Log in, create a project with 1 epic + 1 feature, navigate to the - * Timeline page, fill the start date, click Auto-schedule, and wait + * Timeline page, fill the start date, click Quick schedule, and wait * until the Gantt grid footer ("X features scheduled") is visible. */ async function setupTimeline( @@ -62,12 +62,12 @@ async function setupTimeline( timeout: 8_000, }) - // Set start date, then Auto-schedule + // Set start date, then Quick schedule const dateInput = page.locator('input[type="date"]') await expect(dateInput).toBeVisible({ timeout: 8_000 }) await dateInput.fill('2026-06-01') await expect(dateInput).toHaveValue('2026-06-01') - await page.getByRole('button', { name: /auto-schedule/i }).click() + await quickSchedule(page) // Wait until the Gantt footer appears — it is only rendered once // timeline.entries.length > 0, so it's the earliest reliable signal @@ -83,9 +83,9 @@ async function setupTimeline( test.describe('Gantt Chart', () => { // ────────────────────────────────────────────────────────────────────────── - // 1. Smoke test: feature bars are rendered after auto-schedule + // 1. Smoke test: feature bars are rendered after quick schedule // ────────────────────────────────────────────────────────────────────────── - test('auto-schedule renders feature bars in the Gantt grid', async ({ page }) => { + test('quick schedule renders feature bars in the Gantt grid', async ({ page }) => { await setupTimeline(page) // The footer "X weeks total · X features scheduled" is only rendered when diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 2814c868..f24a8f9d 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -2,7 +2,7 @@ * Shared helpers reused across test files. * Credentials match the test/seed user — override via env vars if needed. */ -import { Page, expect, request } from '@playwright/test' +import { Page, Locator, expect, request } from '@playwright/test' export const TEST_EMAIL = process.env.TEST_EMAIL ?? 'test@example.com' export const TEST_PASSWORD = process.env.TEST_PASSWORD ?? 'password123' @@ -27,6 +27,26 @@ export async function createProject(page: Page, name: string) { await page.getByRole('heading', { name, exact: true }).first().waitFor({ timeout: 10_000 }) } +/** Click the timeline scheduling CTA using the current Timeline UX label. */ +export async function quickSchedule(page: Page) { + const button = page.getByRole('button', { + name: /^quick schedule( again)?$/i, + }).first() + await expect(button).toBeVisible({ timeout: 10_000 }) + await button.click() +} + +/** Open the Starting Team Finder drawer and return its dialog locator. */ +export async function openStartingTeamFinder(page: Page): Promise { + const trigger = page.getByRole('button', { name: /starting team finder/i }).first() + await expect(trigger).toBeVisible({ timeout: 10_000 }) + await trigger.click() + + const drawer = page.getByRole('dialog', { name: /starting team finder/i }) + await expect(drawer).toBeVisible({ timeout: 10_000 }) + return drawer +} + /** * Delete templates by name via the API. Call from afterAll to clean up * any templates created during a test run. diff --git a/e2e/tests/timeline.spec.ts b/e2e/tests/timeline.spec.ts index 0b1ef960..98dac306 100644 --- a/e2e/tests/timeline.spec.ts +++ b/e2e/tests/timeline.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test' -import { login, createProject } from './helpers' +import { login, createProject, openStartingTeamFinder, quickSchedule } from './helpers' import path from 'path' import fs from 'fs' import os from 'os' @@ -7,7 +7,7 @@ import os from 'os' /** * Shared setup for timeline tests 2-4. * Creates a project with one epic + one feature, navigates to Timeline, sets the - * start date to 2026-06-01, clicks Auto-schedule, and waits for Gantt entries + * start date to 2026-06-01, clicks Quick schedule, and waits for Gantt entries * (the sequential/parallel toggle on the epic header row is the earliest reliable * signal that at least one entry has been rendered). */ @@ -48,16 +48,16 @@ async function setupTimeline(page: Page): Promise<{ projectName: string; epicNam await expect(page.getByRole('heading', { name: /timeline planner/i })).toBeVisible({ timeout: 8_000 }) // Set start date — fill triggers React onChange which updates startDateInput state. - // Wait for the DOM value to stabilise before clicking Auto-schedule so that + // Wait for the DOM value to stabilise before clicking Quick schedule so that // handleSchedule reads the correct startDateInput value. const dateInput = page.locator('input[type="date"]') await expect(dateInput).toBeVisible({ timeout: 8_000 }) await dateInput.fill('2026-06-01') await expect(dateInput).toHaveValue('2026-06-01') - // Auto-schedule — the server assigns 1-week default duration to features with no tasks, + // Quick schedule — the server assigns 1-week default duration to features with no tasks, // so even a fresh epic/feature will produce Gantt entries. - await page.getByRole('button', { name: /auto-schedule/i }).click() + await quickSchedule(page) // Wait until the Gantt has at least one entry. The sequential/parallel toggle button // on the epic header row only renders after epicGroups is populated. @@ -145,11 +145,11 @@ test.describe('Timeline', () => { }) }) - test('auto-schedule shows projected end date', async ({ page }) => { + test('quick schedule shows projected end date', async ({ page }) => { await setupTimeline(page) // After setupTimeline the Gantt entries are already visible. The projectedEndDate - // field is rendered next to the Auto-schedule button whenever timeline?.projectedEndDate + // field is rendered next to the Quick schedule action whenever timeline?.projectedEndDate // is truthy. It should appear shortly after scheduling completes. await expect(page.getByText(/projected end:/i)).toBeVisible({ timeout: 15_000 }) }) @@ -184,7 +184,7 @@ test.describe('Timeline', () => { }) // ───────────────────────────────────────────────────────────────────────────── -// Scenario Finder drawer — Phase 4, issue #233 +// Starting Team Finder drawer — Phase 4, issue #233 // ───────────────────────────────────────────────────────────────────────────── /** @@ -203,8 +203,8 @@ const OPTIMISER_CSV = [ /** * Creates a fresh project, seeds it with resource types via CSV import, - * navigates to the Timeline page, and runs Auto-schedule. - * Resources (Developer + Tech Lead) are required for the Run optimiser + * navigates to the Timeline page, and runs Quick schedule. + * Resources (Developer + Tech Lead) are required for the finder action * button to be enabled. */ async function setupOptimiserTimeline(page: Page): Promise { @@ -239,12 +239,12 @@ async function setupOptimiserTimeline(page: Page): Promise { await page.getByRole('button', { name: /timeline/i }).click() await expect(page.getByRole('heading', { name: /timeline planner/i })).toBeVisible({ timeout: 8_000 }) - // Set a start date and run Auto-schedule so the scheduler has produced entries + // Set a start date and run Quick schedule so the scheduler has produced entries const dateInput = page.locator('input[type="date"]') await expect(dateInput).toBeVisible({ timeout: 8_000 }) await dateInput.fill('2026-06-01') await expect(dateInput).toHaveValue('2026-06-01') - await page.getByRole('button', { name: /auto-schedule/i }).click() + await quickSchedule(page) await expect( page.getByRole('button', { name: /sequential|parallel/i }).first() ).toBeVisible({ timeout: 15_000 }) @@ -252,17 +252,12 @@ async function setupOptimiserTimeline(page: Page): Promise { // ── Test 1: open & close ────────────────────────────────────────────────────── // Uses the lighter setupTimeline (no CSV needed for open/close alone). -test.describe('Scenario Finder drawer — open and close', () => { +test.describe('Starting Team Finder drawer — open and close', () => { test('open and close the drawer', async ({ page }) => { await setupTimeline(page) - // Click the header button - await page.getByRole('button', { name: /scenario finder/i }).click() - - // Drawer heading and dialog role should be visible - const drawer = page.getByRole('dialog', { name: /scenario finder/i }) - await expect(drawer).toBeVisible({ timeout: 8_000 }) - await expect(drawer.getByRole('heading', { name: /scenario finder/i })).toBeVisible() + const drawer = await openStartingTeamFinder(page) + await expect(drawer.getByRole('heading', { name: /starting team finder/i })).toBeVisible() // Close via the × button (aria-label="Close") await drawer.getByRole('button', { name: 'Close' }).click() @@ -273,7 +268,7 @@ test.describe('Scenario Finder drawer — open and close', () => { }) // ── Tests 2 & 3: require resource types ────────────────────────────────────── -test.describe('Scenario Finder drawer — with resources', () => { +test.describe('Starting Team Finder drawer — with resources', () => { test.beforeEach(async ({ page }) => { // CSV import + navigation takes ~20-30s; allow 90s total test.setTimeout(90_000) @@ -281,38 +276,32 @@ test.describe('Scenario Finder drawer — with resources', () => { }) test('run optimiser and see results', async ({ page }) => { - // Open the drawer - await page.getByRole('button', { name: /scenario finder/i }).click() - const drawer = page.getByRole('dialog', { name: /scenario finder/i }) - await expect(drawer).toBeVisible({ timeout: 8_000 }) + const drawer = await openStartingTeamFinder(page) - // Click Run optimiser - await drawer.getByRole('button', { name: 'Run optimiser' }).click() + // Click the finder CTA + await drawer.getByRole('button', { name: /find starting teams/i }).click() // Wait for search stats footer (up to 30s for the optimiser to complete) - // Rendered as: "Evaluated X scenarios in Y.Zs" - await expect(drawer.getByText(/Evaluated [\d,]+ scenarios/)).toBeVisible({ timeout: 30_000 }) + // Rendered as: "Evaluated X team options in Y.Zs" + await expect(drawer.getByText(/Evaluated [\d,]+ team options/)).toBeVisible({ timeout: 30_000 }) // Baseline card must be visible - await expect(drawer.getByText('Current configuration')).toBeVisible() + await expect(drawer.getByText('Current starting point')).toBeVisible() - // At least one candidate card — "Top scenarios" heading + at least one Apply button - await expect(drawer.getByText('Top scenarios')).toBeVisible() - await expect(drawer.getByRole('button', { name: 'Apply' }).first()).toBeVisible() + // At least one candidate card — "Starting team options" heading + at least one Apply directly button + await expect(drawer.getByText('Starting team options')).toBeVisible() + await expect(drawer.getByRole('button', { name: /apply directly/i }).first()).toBeVisible() }) test('apply button is present on candidate cards, dialog is dismissed without mutation', async ({ page }) => { - // Open the drawer and run the optimiser - await page.getByRole('button', { name: /scenario finder/i }).click() - const drawer = page.getByRole('dialog', { name: /scenario finder/i }) - await expect(drawer).toBeVisible({ timeout: 8_000 }) + const drawer = await openStartingTeamFinder(page) - await drawer.getByRole('button', { name: 'Run optimiser' }).click() - await expect(drawer.getByText(/Evaluated [\d,]+ scenarios/)).toBeVisible({ timeout: 30_000 }) - await expect(drawer.getByText('Top scenarios')).toBeVisible() + await drawer.getByRole('button', { name: /find starting teams/i }).click() + await expect(drawer.getByText(/Evaluated [\d,]+ team options/)).toBeVisible({ timeout: 30_000 }) + await expect(drawer.getByText('Starting team options')).toBeVisible() - // Each candidate card has a visible Apply button - const applyButtons = drawer.getByRole('button', { name: 'Apply' }) + // Each candidate card has a visible Apply directly button + const applyButtons = drawer.getByRole('button', { name: /apply directly/i }) const count = await applyButtons.count() expect(count).toBeGreaterThan(0) From ba3596b57ebf13b98fff7ff2fe51fb4432587f53 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Fri, 8 May 2026 08:51:10 +1000 Subject: [PATCH 04/11] test: fix timeline drawer selector --- e2e/TESTS.md | 4 ++-- e2e/tests/timeline.spec.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/e2e/TESTS.md b/e2e/TESTS.md index 4af33eef..d7c14368 100644 --- a/e2e/TESTS.md +++ b/e2e/TESTS.md @@ -129,8 +129,8 @@ API-level tests using the `request` fixture. No browser UI involved. | Test | Description | |------|-------------| -| run optimiser and see results | Opens drawer, clicks `Find starting teams`, waits up to 30 s for the search-stats footer (`Evaluated X team options in Ys`), asserts the baseline card ("Current starting point") and at least one candidate card with an `Apply directly` button are visible | -| apply button is present on candidate cards, dialog is dismissed without mutation | Runs the finder, asserts every candidate card has an `Apply directly` button, clicks the first one, dismisses the browser `confirm()` dialog, and asserts the drawer remains open (no snapshot was created) | +| run optimiser and see results | Opens drawer, clicks `Find starting teams`, waits up to 30 s for the search-stats footer (`Evaluated X team options in Ys`), asserts the baseline card ("Current starting point"), the exact `Starting team options` section label, and at least one candidate card with an `Apply directly` button are visible | +| apply button is present on candidate cards, dialog is dismissed without mutation | Runs the finder, asserts the exact `Starting team options` section label and that candidate cards expose `Apply directly` buttons, clicks the first one, dismisses the browser `confirm()` dialog, and asserts the drawer remains open (no snapshot was created) | --- diff --git a/e2e/tests/timeline.spec.ts b/e2e/tests/timeline.spec.ts index 98dac306..f2d0395c 100644 --- a/e2e/tests/timeline.spec.ts +++ b/e2e/tests/timeline.spec.ts @@ -288,8 +288,9 @@ test.describe('Starting Team Finder drawer — with resources', () => { // Baseline card must be visible await expect(drawer.getByText('Current starting point')).toBeVisible() - // At least one candidate card — "Starting team options" heading + at least one Apply directly button - await expect(drawer.getByText('Starting team options')).toBeVisible() + // At least one candidate card — the exact "Starting team options" section label + at least one Apply directly button + const startingTeamOptions = drawer.locator('div').filter({ hasText: /^Starting team options$/ }).first() + await expect(startingTeamOptions).toBeVisible() await expect(drawer.getByRole('button', { name: /apply directly/i }).first()).toBeVisible() }) @@ -298,7 +299,8 @@ test.describe('Starting Team Finder drawer — with resources', () => { await drawer.getByRole('button', { name: /find starting teams/i }).click() await expect(drawer.getByText(/Evaluated [\d,]+ team options/)).toBeVisible({ timeout: 30_000 }) - await expect(drawer.getByText('Starting team options')).toBeVisible() + const startingTeamOptions = drawer.locator('div').filter({ hasText: /^Starting team options$/ }).first() + await expect(startingTeamOptions).toBeVisible() // Each candidate card has a visible Apply directly button const applyButtons = drawer.getByRole('button', { name: /apply directly/i }) From 0f12fc807710c195b937d59e03fdeb1e8f54e4e4 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Fri, 8 May 2026 14:29:54 +1000 Subject: [PATCH 05/11] fix(#233): handle expired auth sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- client/src/hooks/useAuth.tsx | 36 ++++++-------- client/src/lib/api.ts | 9 ++-- client/src/lib/authSession.ts | 73 ++++++++++++++++++++++++++++ client/src/test/authSession.test.tsx | 64 ++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 client/src/lib/authSession.ts create mode 100644 client/src/test/authSession.test.tsx diff --git a/client/src/hooks/useAuth.tsx b/client/src/hooks/useAuth.tsx index a62b131f..2d3fd0bb 100644 --- a/client/src/hooks/useAuth.tsx +++ b/client/src/hooks/useAuth.tsx @@ -1,10 +1,16 @@ -import { createContext, useContext, useState } from 'react' +import { createContext, useContext, useEffect, useState } from 'react' import type { ReactNode } from 'react' import { api } from '../lib/api' +import { + clearAuthSession, + getStoredUser, + setAuthSession, + subscribeToAuthSession, + type AuthUser, +} from '../lib/authSession' -interface User { id: string; email: string; name: string } interface AuthContextType { - user: User | null + user: AuthUser | null login: (email: string, password: string) => Promise register: (email: string, name: string, password: string) => Promise logout: () => void @@ -13,21 +19,13 @@ interface AuthContextType { const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(() => { - const stored = localStorage.getItem('user') - try { - return stored ? JSON.parse(stored) : null - } catch { - localStorage.removeItem('user') - return null - } - }) + const [user, setUser] = useState(() => getStoredUser()) + + useEffect(() => subscribeToAuthSession(setUser), []) const login = async (email: string, password: string) => { const { data } = await api.post('/auth/login', { email, password }) - localStorage.setItem('token', data.token) - localStorage.setItem('user', JSON.stringify(data.user)) - setUser(data.user) + setAuthSession(data.token, data.user) } const register = async (email: string, name: string, password: string): Promise => { @@ -35,18 +33,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Server returns no token for existing emails (enumeration prevention). // Only log the user in when a real token is returned (HTTP 201). if (data.token) { - localStorage.setItem('token', data.token) - localStorage.setItem('user', JSON.stringify(data.user)) - setUser(data.user) + setAuthSession(data.token, data.user) return true // navigable — user is now logged in } return false // existing email — show generic success, don't navigate } const logout = () => { - localStorage.removeItem('token') - localStorage.removeItem('user') - setUser(null) + clearAuthSession() } return {children} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 572a9cb0..9e2c8220 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { clearAuthSession, getStoredToken } from './authSession' const baseURL = import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api` @@ -7,7 +8,7 @@ const baseURL = import.meta.env.VITE_API_URL export const api = axios.create({ baseURL, timeout: 30000 }) api.interceptors.request.use((config) => { - const token = localStorage.getItem('token') + const token = getStoredToken() if (token) config.headers.Authorization = `Bearer ${token}` return config }) @@ -17,8 +18,10 @@ api.interceptors.response.use( (err) => { const isAuthRoute = err.config?.url?.startsWith('/auth/') if (err.response?.status === 401 && !isAuthRoute) { - localStorage.removeItem('token') - window.location.href = '/login' + clearAuthSession() + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } return Promise.reject(err) } diff --git a/client/src/lib/authSession.ts b/client/src/lib/authSession.ts new file mode 100644 index 00000000..c55667d6 --- /dev/null +++ b/client/src/lib/authSession.ts @@ -0,0 +1,73 @@ +export interface AuthUser { + id: string + email: string + name: string +} + +const TOKEN_STORAGE_KEY = 'token' +const USER_STORAGE_KEY = 'user' +const AUTH_SESSION_CHANGED_EVENT = 'auth:session-changed' + +function dispatchAuthSessionChanged(user: AuthUser | null) { + if (typeof window === 'undefined') return + + window.dispatchEvent(new CustomEvent(AUTH_SESSION_CHANGED_EVENT, { + detail: { user }, + })) +} + +export function getStoredToken() { + return localStorage.getItem(TOKEN_STORAGE_KEY) +} + +export function getStoredUser(): AuthUser | null { + const storedUser = localStorage.getItem(USER_STORAGE_KEY) + + if (!storedUser) return null + + try { + return JSON.parse(storedUser) as AuthUser + } catch { + clearAuthSession() + return null + } +} + +export function setAuthSession(token: string, user: AuthUser) { + localStorage.setItem(TOKEN_STORAGE_KEY, token) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)) + dispatchAuthSessionChanged(user) +} + +export function clearAuthSession() { + localStorage.removeItem(TOKEN_STORAGE_KEY) + localStorage.removeItem(USER_STORAGE_KEY) + dispatchAuthSessionChanged(null) +} + +interface AuthSessionChangedDetail { + user: AuthUser | null +} + +export function subscribeToAuthSession(callback: (user: AuthUser | null) => void) { + if (typeof window === 'undefined') return () => {} + + const handleSessionChanged = (event: Event) => { + const detail = (event as CustomEvent).detail + callback(detail?.user ?? getStoredUser()) + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === null || event.key === TOKEN_STORAGE_KEY || event.key === USER_STORAGE_KEY) { + callback(getStoredUser()) + } + } + + window.addEventListener(AUTH_SESSION_CHANGED_EVENT, handleSessionChanged as EventListener) + window.addEventListener('storage', handleStorage) + + return () => { + window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, handleSessionChanged as EventListener) + window.removeEventListener('storage', handleStorage) + } +} diff --git a/client/src/test/authSession.test.tsx b/client/src/test/authSession.test.tsx new file mode 100644 index 00000000..b06651ff --- /dev/null +++ b/client/src/test/authSession.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, beforeEach } from 'vitest' +import { AuthProvider, useAuth } from '@/hooks/useAuth' +import { clearAuthSession, getStoredUser, setAuthSession } from '@/lib/authSession' + +function AuthState() { + const { user } = useAuth() + + return
{user ? user.email : 'logged-out'}
+} + +describe('auth session', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('stores and clears token and user together', () => { + const user = { id: 'user-1', email: 'alex@example.com', name: 'Alex' } + + setAuthSession('token-123', user) + + expect(localStorage.getItem('token')).toBe('token-123') + expect(localStorage.getItem('user')).toBe(JSON.stringify(user)) + expect(getStoredUser()).toEqual(user) + + act(() => { + clearAuthSession() + }) + + expect(localStorage.getItem('token')).toBeNull() + expect(localStorage.getItem('user')).toBeNull() + expect(getStoredUser()).toBeNull() + }) + + it('updates AuthProvider immediately when the session is cleared externally', async () => { + setAuthSession('token-123', { id: 'user-1', email: 'alex@example.com', name: 'Alex' }) + + render( + + + , + ) + + expect(screen.getByText('alex@example.com')).toBeInTheDocument() + + act(() => { + clearAuthSession() + }) + + await waitFor(() => { + expect(screen.getByText('logged-out')).toBeInTheDocument() + }) + }) + + it('clears an invalid stored session payload', () => { + localStorage.setItem('token', 'token-123') + localStorage.setItem('user', '{bad json') + + expect(getStoredUser()).toBeNull() + expect(localStorage.getItem('token')).toBeNull() + expect(localStorage.getItem('user')).toBeNull() + }) +}) From d4b95b03335ed1e1edd6227bee7e180a31e85df8 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Fri, 8 May 2026 14:37:40 +1000 Subject: [PATCH 06/11] Fix reset password login rate limit --- server/src/routes/auth.ts | 7 +++ server/src/test/auth.test.ts | 100 +++++++++++++++++++++++++++++++++++ server/src/test/setup.ts | 2 +- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 server/src/test/auth.test.ts diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 584bbfce..07c3a18a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -20,10 +20,12 @@ const router = Router() // Skip rate limiting in test environments so Playwright/Vitest suites are not // throttled by their own repeated auth calls from the same IP (127.0.0.1). const skipInTest = () => process.env.NODE_ENV === 'test' +const getLoginLimiterKey = (req: Request) => req.ip ?? req.socket.remoteAddress ?? 'unknown' const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, + keyGenerator: getLoginLimiterKey, skip: skipInTest, message: { error: 'Too many login attempts, please try again in 15 minutes' }, standardHeaders: true, @@ -149,8 +151,13 @@ router.post('/reset-password', validate(resetPasswordSchema), asyncHandler(async data: { password: hashed }, }) + await Promise.resolve(loginLimiter.resetKey(getLoginLimiterKey(req))).catch((error: unknown) => { + logger.warn({ error, ip: req.ip, userId: resetToken.userId }, 'Failed to clear login rate limit after password reset') + }) + logger.info({ userId: resetToken.userId }, 'Password reset successfully') res.json({ message: 'Password reset successfully' }) })) export default router +export { loginLimiter } diff --git a/server/src/test/auth.test.ts b/server/src/test/auth.test.ts new file mode 100644 index 00000000..5d09842b --- /dev/null +++ b/server/src/test/auth.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import request from 'supertest' +import bcrypt from 'bcryptjs' +import crypto from 'crypto' +import { app } from '../index.js' +import { prisma } from '../lib/prisma.js' +import { loginLimiter } from '../routes/auth.js' + +const email = 'user@example.com' +const userId = 'user-1' +const resetToken = 'reset-token' +const originalNodeEnv = process.env.NODE_ENV +const loopbackKeys = ['::1', '::ffff:127.0.0.1', '127.0.0.1'] + +async function clearLoginLimiter() { + await Promise.all(loopbackKeys.map(async (key) => { + await Promise.resolve(loginLimiter.resetKey(key)).catch(() => undefined) + })) +} + +describe('auth password reset rate-limit regression', () => { + beforeEach(async () => { + vi.clearAllMocks() + process.env.NODE_ENV = 'development' + await clearLoginLimiter() + + let currentPasswordHash = await bcrypt.hash('OriginalPassword123!', 10) + const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex') + + ;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => { + if (where.email !== email) return null + + return { + id: userId, + email, + name: 'Test User', + role: 'USER', + password: currentPasswordHash, + } as any + }) + + ;(prisma.passwordResetToken.update as any).mockImplementation(async ({ where }: any) => { + expect(where).toEqual({ tokenHash: resetTokenHash, usedAt: null }) + + return { + userId, + tokenHash: resetTokenHash, + expiresAt: new Date(Date.now() + 60_000), + usedAt: new Date(), + } as any + }) + + ;(prisma.user.update as any).mockImplementation(async ({ where, data }: any) => { + expect(where).toEqual({ id: userId }) + currentPasswordHash = data.password + + return { + id: userId, + email, + name: 'Test User', + role: 'USER', + password: currentPasswordHash, + } as any + }) + }) + + afterEach(async () => { + process.env.NODE_ENV = originalNodeEnv + await clearLoginLimiter() + }) + + it('allows immediate login after a successful password reset clears prior failed login attempts', async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + const failedLogin = await request(app) + .post('/api/auth/login') + .send({ email, password: 'WrongPassword123!' }) + + expect(failedLogin.status).toBe(401) + } + + const resetResponse = await request(app) + .post('/api/auth/reset-password') + .send({ token: resetToken, password: 'NewPassword123!' }) + + expect(resetResponse.status).toBe(200) + expect(resetResponse.body).toEqual({ message: 'Password reset successfully' }) + + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ email, password: 'NewPassword123!' }) + + expect(loginResponse.status).toBe(200) + expect(loginResponse.body.user).toMatchObject({ + id: userId, + email, + name: 'Test User', + }) + expect(loginResponse.body.token).toEqual(expect.any(String)) + }) +}) diff --git a/server/src/test/setup.ts b/server/src/test/setup.ts index 9dee8ce8..e77728cb 100644 --- a/server/src/test/setup.ts +++ b/server/src/test/setup.ts @@ -13,7 +13,7 @@ vi.mock('../lib/scopeDocumentRenderer.js', () => ({ // Mock Prisma globally so tests don't need a real DB vi.mock('../lib/prisma.js', () => ({ prisma: { - user: { findUnique: vi.fn(), create: vi.fn() }, + user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, project: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn() }, epic: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, feature: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, From 20c73b7ea9d9d587a377a62e3072a86e82a310ae Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Mon, 11 May 2026 09:58:43 +1000 Subject: [PATCH 07/11] Fix timeline named resource allocation derivation --- server/src/lib/namedResourceAssignments.ts | 13 +- .../src/test/namedResourceAssignments.test.ts | 147 ++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 server/src/test/namedResourceAssignments.test.ts diff --git a/server/src/lib/namedResourceAssignments.ts b/server/src/lib/namedResourceAssignments.ts index 6349af2c..8e5005d2 100644 --- a/server/src/lib/namedResourceAssignments.ts +++ b/server/src/lib/namedResourceAssignments.ts @@ -178,11 +178,14 @@ function weeklyCapacityForNamedResource( if (mode === 'EFFORT') return 5 if (mode === 'FULL_PROJECT' || mode === 'CAPACITY_PLAN') return 5 * (allocationPercent / 100) - const effectiveStartWeek = namedResource.allocationStartWeek ?? namedResource.startWeek ?? 0 - const effectiveEndWeek = - namedResource.allocationEndWeek ?? - namedResource.endWeek ?? - effectiveStartWeek + const hasExplicitAllocationWindow = + namedResource.allocationStartWeek != null || + namedResource.allocationEndWeek != null + + if (!hasExplicitAllocationWindow) return 5 * (allocationPercent / 100) + + const effectiveStartWeek = namedResource.allocationStartWeek ?? -Infinity + const effectiveEndWeek = namedResource.allocationEndWeek ?? Infinity if (week < effectiveStartWeek || week > effectiveEndWeek) return 0 return 5 * (allocationPercent / 100) diff --git a/server/src/test/namedResourceAssignments.test.ts b/server/src/test/namedResourceAssignments.test.ts new file mode 100644 index 00000000..6e1361d7 --- /dev/null +++ b/server/src/test/namedResourceAssignments.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { deriveNamedResourceAssignments } from '../lib/namedResourceAssignments.js' + +describe('deriveNamedResourceAssignments', () => { + it('keeps TIMELINE named resources available across all demand weeks when allocation window is omitted', () => { + const assignments = deriveNamedResourceAssignments({ + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + count: 1, + allocationMode: 'TIMELINE', + namedResources: [ + { + id: 'nr-dev-1', + name: 'Dev 1', + startWeek: null, + endWeek: null, + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + ], + }, + ], + weeklyDemand: [ + { week: 3, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 4, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 7, resourceTypeName: 'Developer', demandDays: 2 }, + ], + }) + + expect(assignments.get('rt-dev')).toEqual( + expect.objectContaining({ + actualAllocatedDays: 12, + unallocatedDays: 0, + namedResources: [ + expect.objectContaining({ + id: 'nr-dev-1', + actualAllocatedDays: 12, + actualAllocationStartWeek: 3, + actualAllocationEndWeek: 7, + actualAllocatedWeeks: [ + { week: 3, days: 5, capacityDays: 5 }, + { week: 4, days: 5, capacityDays: 5 }, + { week: 7, days: 2, capacityDays: 5 }, + ], + }), + ], + }), + ) + }) + + it('still applies named-resource start/end gating when TIMELINE allocation window is omitted', () => { + const assignments = deriveNamedResourceAssignments({ + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + count: 1, + allocationMode: 'TIMELINE', + namedResources: [ + { + id: 'nr-dev-1', + name: 'Dev 1', + startWeek: 4, + endWeek: 5, + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + }, + ], + }, + ], + weeklyDemand: [ + { week: 3, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 4, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 5, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 6, resourceTypeName: 'Developer', demandDays: 5 }, + ], + }) + + expect(assignments.get('rt-dev')).toEqual( + expect.objectContaining({ + actualAllocatedDays: 10, + unallocatedDays: 10, + namedResources: [ + expect.objectContaining({ + id: 'nr-dev-1', + actualAllocatedWeeks: [ + { week: 4, days: 5, capacityDays: 5 }, + { week: 5, days: 5, capacityDays: 5 }, + ], + }), + ], + }), + ) + }) + + it('keeps explicit TIMELINE allocationStartWeek open-ended when allocationEndWeek is omitted', () => { + const assignments = deriveNamedResourceAssignments({ + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + count: 1, + allocationMode: 'TIMELINE', + namedResources: [ + { + id: 'nr-dev-1', + name: 'Dev 1', + startWeek: null, + endWeek: null, + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: 4, + allocationEndWeek: null, + }, + ], + }, + ], + weeklyDemand: [ + { week: 3, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 4, resourceTypeName: 'Developer', demandDays: 5 }, + { week: 5, resourceTypeName: 'Developer', demandDays: 5 }, + ], + }) + + expect(assignments.get('rt-dev')).toEqual( + expect.objectContaining({ + actualAllocatedDays: 10, + unallocatedDays: 5, + namedResources: [ + expect.objectContaining({ + id: 'nr-dev-1', + actualAllocatedWeeks: [ + { week: 4, days: 5, capacityDays: 5 }, + { week: 5, days: 5, capacityDays: 5 }, + ], + }), + ], + }), + ) + }) +}) From 93cc09f899ab70a8c0fb222d43fde24152a3cc0f Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Mon, 11 May 2026 10:10:19 +1000 Subject: [PATCH 08/11] Fix resource profile allocation sync --- client/src/lib/timelineQueryInvalidation.ts | 23 ++++ client/src/pages/TimelinePage.tsx | 35 +++--- .../test/timelineQueryInvalidation.test.ts | 27 +++++ server/src/routes/resourceProfile.ts | 49 ++++++-- server/src/test/resourceProfile.test.ts | 113 ++++++++++++++++++ 5 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 client/src/lib/timelineQueryInvalidation.ts create mode 100644 client/src/test/timelineQueryInvalidation.test.ts diff --git a/client/src/lib/timelineQueryInvalidation.ts b/client/src/lib/timelineQueryInvalidation.ts new file mode 100644 index 00000000..61b0ec1b --- /dev/null +++ b/client/src/lib/timelineQueryInvalidation.ts @@ -0,0 +1,23 @@ +import type { QueryClient } from '@tanstack/react-query' + +type QueryInvalidator = Pick + +export function invalidateTimelineDerivedQueries( + queryClient: QueryInvalidator, + projectId: string | undefined, +) { + if (!projectId) return + + queryClient.invalidateQueries({ queryKey: ['timeline', projectId] }) + queryClient.invalidateQueries({ queryKey: ['resource-profile', projectId] }) +} + +export function invalidateResourceTypeDerivedQueries( + queryClient: QueryInvalidator, + projectId: string | undefined, +) { + if (!projectId) return + + queryClient.invalidateQueries({ queryKey: ['resource-types', projectId] }) + queryClient.invalidateQueries({ queryKey: ['resource-profile', projectId] }) +} diff --git a/client/src/pages/TimelinePage.tsx b/client/src/pages/TimelinePage.tsx index a3f07e13..e5d20ffb 100644 --- a/client/src/pages/TimelinePage.tsx +++ b/client/src/pages/TimelinePage.tsx @@ -21,6 +21,10 @@ import { type SquadPlannerSeedSettings, } from '../components/timeline/timelineUx' import SnapshotHistoryPanel from '../components/SnapshotHistoryPanel' +import { + invalidateResourceTypeDerivedQueries, + invalidateTimelineDerivedQueries, +} from '../lib/timelineQueryInvalidation' const CATEGORY_HEADER_BG: Record = { ENGINEERING: 'bg-blue-100', @@ -456,10 +460,10 @@ export default function TimelinePage() { } }, [timeline, project]) - const invalidate = () => qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + const invalidate = () => invalidateTimelineDerivedQueries(qc, projectId) const handleOptimiserApplied = (snapshotId: string) => { - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) qc.invalidateQueries({ queryKey: ['snapshots', projectId] }) setOptimiserOpen(false) alert(`Starting team applied (snapshot ${snapshotId}). Roll back via the History panel if needed.`) @@ -487,6 +491,7 @@ export default function TimelinePage() { onSuccess: (data) => { qc.setQueryData(['timeline', projectId], data) qc.invalidateQueries({ queryKey: ['project', projectId] }) + qc.invalidateQueries({ queryKey: ['resource-profile', projectId] }) }, }) @@ -525,7 +530,7 @@ export default function TimelinePage() { // Refresh both the dep list (sidebar badges) and the timeline (Gantt arrows). // Do NOT call updateEntry here — that would set isManual=true as a side effect. qc.invalidateQueries({ queryKey: ['feature-deps', projectId] }) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -535,7 +540,7 @@ export default function TimelinePage() { api.delete(`/projects/${projectId}/feature-dependencies/${featureId}/${dependsOnId}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['feature-deps', projectId] }) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -551,7 +556,7 @@ export default function TimelinePage() { api.post(`/projects/${projectId}/epic-dependencies`, { epicId, dependsOnId }).then(r => r.data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['epicDeps', projectId] }) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -561,7 +566,7 @@ export default function TimelinePage() { api.delete(`/projects/${projectId}/epic-dependencies/${epicId}/${dependsOnId}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['epicDeps', projectId] }) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -587,7 +592,7 @@ export default function TimelinePage() { const updateEpicMode = useMutation({ mutationFn: ({ epicId, featureMode }: { epicId: string; featureMode: string }) => api.put(`/projects/${projectId}/epics/${epicId}`, { featureMode }).then(r => r.data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['timeline', projectId] }); setScheduleStale(true) }, + onSuccess: () => { invalidateTimelineDerivedQueries(qc, projectId); setScheduleStale(true) }, }) const updateEpicScheduleMode = useMutation({ @@ -595,7 +600,7 @@ export default function TimelinePage() { api.put(`/projects/${projectId}/epics/${epicId}`, { scheduleMode }).then(r => r.data), onSuccess: () => { setScheduleStale(true) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) }, }) @@ -607,7 +612,7 @@ export default function TimelinePage() { if (data.dayRate !== undefined) payload.dayRate = data.dayRate return api.put(`/projects/${projectId}/resource-types/${id}`, payload).then(r => r.data) }, - onSuccess: () => { qc.invalidateQueries({ queryKey: ['resource-types', projectId] }); setScheduleStale(true) }, + onSuccess: () => { invalidateResourceTypeDerivedQueries(qc, projectId); setScheduleStale(true) }, }) const addNamedResource = useMutation({ @@ -617,7 +622,7 @@ export default function TimelinePage() { allocationPct: 100, }).then(r => r.data), onSuccess: () => { - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -626,7 +631,7 @@ export default function TimelinePage() { mutationFn: ({ rtId, nrId }: { rtId: string; nrId: string }) => api.delete(`/projects/${projectId}/resource-types/${rtId}/named-resources/${nrId}`).then(r => r.data), onSuccess: () => { - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -635,7 +640,7 @@ export default function TimelinePage() { mutationFn: ({ rtId, nrId, allocationMode, allocationPercent, allocationStartWeek, allocationEndWeek }: { rtId: string; nrId: string; allocationMode: string; allocationPercent: number; allocationStartWeek?: number | null; allocationEndWeek?: number | null }) => api.patch(`/projects/${projectId}/resource-types/${rtId}/named-resources/${nrId}`, { allocationMode, allocationPercent, allocationStartWeek, allocationEndWeek }).then(r => r.data), onSuccess: () => { - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) setScheduleStale(true) }, }) @@ -670,7 +675,7 @@ export default function TimelinePage() { const levelMutation = useMutation({ mutationFn: () => api.post(`/projects/${projectId}/timeline/level`, { dryRun: false }).then(r => r.data), onSuccess: () => { - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) qc.invalidateQueries({ queryKey: ['project', projectId] }) }, }) @@ -689,7 +694,7 @@ export default function TimelinePage() { api.patch(`/projects/${projectId}/reorder/epics`, { items }).then(r => r.data), onSuccess: () => { setScheduleStale(true) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) }, }) @@ -698,7 +703,7 @@ export default function TimelinePage() { api.patch(`/projects/${projectId}/reorder/features`, { items }).then(r => r.data), onSuccess: () => { setScheduleStale(true) - qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + invalidateTimelineDerivedQueries(qc, projectId) }, }) diff --git a/client/src/test/timelineQueryInvalidation.test.ts b/client/src/test/timelineQueryInvalidation.test.ts new file mode 100644 index 00000000..54781565 --- /dev/null +++ b/client/src/test/timelineQueryInvalidation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { + invalidateResourceTypeDerivedQueries, + invalidateTimelineDerivedQueries, +} from '@/lib/timelineQueryInvalidation' + +describe('timelineQueryInvalidation', () => { + it('invalidates timeline and resource-profile queries together', () => { + const invalidateQueries = vi.fn() + + invalidateTimelineDerivedQueries({ invalidateQueries } as never, 'project-1') + + expect(invalidateQueries).toHaveBeenCalledTimes(2) + expect(invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ['timeline', 'project-1'] }) + expect(invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ['resource-profile', 'project-1'] }) + }) + + it('invalidates resource-types and resource-profile queries together', () => { + const invalidateQueries = vi.fn() + + invalidateResourceTypeDerivedQueries({ invalidateQueries } as never, 'project-1') + + expect(invalidateQueries).toHaveBeenCalledTimes(2) + expect(invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ['resource-types', 'project-1'] }) + expect(invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ['resource-profile', 'project-1'] }) + }) +}) diff --git a/server/src/routes/resourceProfile.ts b/server/src/routes/resourceProfile.ts index 0906b87a..2eb16211 100644 --- a/server/src/routes/resourceProfile.ts +++ b/server/src/routes/resourceProfile.ts @@ -354,11 +354,27 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { } }) : resourceType.namedResources + const actualNamedResourceAssignment = namedResourceAssignments.get(resourceType.id) + const actualNamedResourcesForType = actualNamedResourceAssignment?.namedResources ?? [] + const actualNamedResourceStartWeeks = actualNamedResourcesForType + .map(namedResource => namedResource.actualAllocationStartWeek) + .filter((week): week is number => week != null) + const actualNamedResourceEndWeeks = actualNamedResourcesForType + .map(namedResource => namedResource.actualAllocationEndWeek) + .filter((week): week is number => week != null) + const actualDerivedStartWeek = actualNamedResourceStartWeeks.length > 0 + ? Math.min(...actualNamedResourceStartWeeks) + : null + const actualDerivedEndWeek = actualNamedResourceEndWeeks.length > 0 + ? Math.max(...actualNamedResourceEndWeeks) + : null // If named resources exist, compute per-NR allocatedDays const hasNamedResources = namedResourcesSource.length > 0 let allocatedDays: number + let rowDerivedStartWeek = derivedStartWeek + let rowDerivedEndWeek = derivedEndWeek let namedResourcesOutput: Array<{ id: string name: string @@ -382,9 +398,8 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { if (hasNamedResources) { // Compute per-NR allocated days namedResourcesOutput = namedResourcesSource.map(nr => { - const actualNamedResource = namedResourceAssignments - .get(resourceType.id) - ?.namedResources.find(actual => actual.id === nr.id || actual.name === nr.name) + const actualNamedResource = actualNamedResourcesForType + .find(actual => actual.id === nr.id || actual.name === nr.name) const nrMode = (nr.allocationMode as AllocationMode) ?? 'EFFORT' const nrPercent = nr.allocationPercent ?? 100 let nrAllocatedDays: number @@ -427,9 +442,8 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { } }) const existingIds = new Set(namedResourcesOutput.map(nr => nr.id)) - const syntheticAssignments = namedResourceAssignments - .get(resourceType.id) - ?.namedResources.filter(actual => !existingIds.has(actual.id)) ?? [] + const syntheticAssignments = actualNamedResourcesForType + .filter(actual => !existingIds.has(actual.id)) namedResourcesOutput.push(...syntheticAssignments.map(actual => ({ id: actual.id, name: actual.name, @@ -449,8 +463,23 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { actualAllocationSegments: actual.actualAllocationSegments, synthetic: actual.synthetic, }))) - // Total RT allocatedDays = sum of NR allocatedDays - allocatedDays = round2(namedResourcesOutput.reduce((sum, nr) => sum + nr.allocatedDays, 0)) + const plannedAllocatedDays = round2(namedResourcesOutput.reduce((sum, nr) => sum + nr.allocatedDays, 0)) + const actualAllocatedDays = round2(actualNamedResourceAssignment?.actualAllocatedDays ?? 0) + const shouldUseActualAssignmentWindow = + actualDerivedStartWeek != null && + actualDerivedEndWeek != null && + ( + derivedStartWeek == null || + actualDerivedStartWeek < derivedStartWeek || + derivedEndWeek == null || + actualDerivedEndWeek > derivedEndWeek + ) + + allocatedDays = round2(Math.max(plannedAllocatedDays, actualAllocatedDays)) + if (shouldUseActualAssignmentWindow) { + rowDerivedStartWeek = actualDerivedStartWeek + rowDerivedEndWeek = actualDerivedEndWeek + } } else { namedResourcesOutput = [] if (mode === 'CAPACITY_PLAN') { @@ -487,8 +516,8 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { allocationPercent: percent, allocationStartWeek: resourceType.allocationStartWeek ?? null, allocationEndWeek: resourceType.allocationEndWeek ?? null, - derivedStartWeek, - derivedEndWeek, + derivedStartWeek: rowDerivedStartWeek, + derivedEndWeek: rowDerivedEndWeek, estimatedCost, epics, namedResources: namedResourcesOutput, diff --git a/server/src/test/resourceProfile.test.ts b/server/src/test/resourceProfile.test.ts index 742651ed..ecb0d7d7 100644 --- a/server/src/test/resourceProfile.test.ts +++ b/server/src/test/resourceProfile.test.ts @@ -385,4 +385,117 @@ describe('GET /api/projects/:projectId/resource-profile', () => { }), ]) }) + + it('uses actual named-resource assignment coverage when it exceeds the derived timeline window', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: 'proj-1', + ownerId: userId, + hoursPerDay: 8, + bufferWeeks: 0, + onboardingWeeks: 0, + weeklyDemandCache: { + 'Data, AI & IoT|0': 5, + 'Data, AI & IoT|1': 5, + 'Data, AI & IoT|2': 5, + 'Data, AI & IoT|3': 5, + 'Data, AI & IoT|4': 5, + 'Data, AI & IoT|5': 5, + 'Data, AI & IoT|6': 5, + 'Data, AI & IoT|7': 5, + 'Data, AI & IoT|8': 5, + 'Data, AI & IoT|9': 5, + 'Data, AI & IoT|10': 5, + 'Data, AI & IoT|11': 5, + 'Data, AI & IoT|12': 1, + }, + resourceTypes: [ + { + id: 'rt-data', + name: 'Data, AI & IoT', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-data', + name: 'Senior Engineer - Data, AI & IoT', + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + ], + epics: [ + { + id: 'epic-1', + name: 'Platform', + order: 0, + isActive: true, + features: [ + { + id: 'feat-1', + name: 'Delivery', + order: 0, + isActive: true, + userStories: [ + { + id: 'story-1', + name: 'Implement', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + hoursEffort: 488, + durationDays: 61, + resourceTypeId: 'rt-data', + }, + ], + }, + ], + }, + ], + }, + ], + overheads: [], + timelineEntries: [ + { featureId: 'feat-1', startWeek: 4.327272727272727, durationWeeks: 8.072727272727272 }, + ], + storyTimelineEntries: [], + capacityPlans: [], + } as any) + + const res = await request(app) + .get('/api/projects/proj-1/resource-profile') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const dataRow = res.body.resourceRows.find((row: any) => row.resourceTypeId === 'rt-data') + expect(dataRow).toMatchObject({ + allocationMode: 'TIMELINE', + allocatedDays: 61, + derivedStartWeek: 0, + derivedEndWeek: 12, + }) + expect(dataRow.namedResources).toEqual([ + expect.objectContaining({ + name: 'Senior Engineer - Data, AI & IoT', + allocatedDays: 40.36, + actualAllocatedDays: 61, + actualAllocationStartWeek: 0, + actualAllocationEndWeek: 12, + }), + ]) + }) }) From 59505584c4eeeacb61f42533c2e841290fbaf316 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Mon, 11 May 2026 15:40:26 +1000 Subject: [PATCH 09/11] Fix timeline parallel warning epsilon --- server/src/lib/scheduler.ts | 4 +++- server/src/test/scheduler.test.ts | 36 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/server/src/lib/scheduler.ts b/server/src/lib/scheduler.ts index f8e6a310..00715c1e 100644 --- a/server/src/lib/scheduler.ts +++ b/server/src/lib/scheduler.ts @@ -123,6 +123,8 @@ export interface SchedulerOutput { parallelWarnings: ParallelWarning[] } +const PARALLEL_WARNING_EPSILON_DAYS = 1e-9 + // ───────────────────────────────────────────────────────────────────────────── // Pure helpers (previously in routes/timeline.ts) // ───────────────────────────────────────────────────────────────────────────── @@ -310,7 +312,7 @@ export function computeParallelWarnings( capacityDays += (getWeeklyCapacity(rt, w, fallbackHoursPerDay) / hpd) * overlap } } - if (days > capacityDays) { + if ((days - capacityDays) > PARALLEL_WARNING_EPSILON_DAYS) { warnings.push({ epicId, epicName, diff --git a/server/src/test/scheduler.test.ts b/server/src/test/scheduler.test.ts index 05ae4c69..ce11bd04 100644 --- a/server/src/test/scheduler.test.ts +++ b/server/src/test/scheduler.test.ts @@ -443,6 +443,42 @@ describe('runScheduler', () => { expect(widerWarnings.length).toBe(0) }) + it('computeParallelWarnings: ignores floating-point residue when demand matches partial-week capacity', () => { + const taskRT = { id: 'rt-security', name: 'Security', hoursPerDay: 8 } + const allFeatures = [ + { id: 'f1', userStories: [{ isActive: true as boolean | null, tasks: [{ resourceTypeId: 'rt-security', resourceType: taskRT, hoursEffort: 78, durationDays: null as number | null }] }] }, + { id: 'f2', userStories: [{ isActive: true as boolean | null, tasks: [{ resourceTypeId: 'rt-security', resourceType: taskRT, hoursEffort: 78, durationDays: null as number | null }] }] }, + ] + const epicMeta = { id: 'e-security', name: 'Security', featureMode: 'parallel' } + const entries = [ + { featureId: 'f1', startWeek: 14.8, durationWeeks: 7.8, feature: { epic: epicMeta } }, + { featureId: 'f2', startWeek: 14.8, durationWeeks: 7.8, feature: { epic: epicMeta } }, + ] + const rt: SchedulerResourceType = { + id: 'rt-security', + name: 'Security', + count: 1, + hoursPerDay: 8, + namedResources: [ + { + id: 'nr-security', + name: 'Principal Consultant - Security', + startWeek: 0, + endWeek: null, + allocationPct: 100, + allocationMode: 'FULL_PROJECT', + allocationPercent: 50, + allocationStartWeek: null, + allocationEndWeek: null, + }, + ], + } + + const warnings = computeParallelWarnings(8, entries, allFeatures, [rt]) + + expect(warnings).toEqual([]) + }) + // ── Resource-levelling: consumption map populated ──────────────────────────── it('resourceLevel=true: weeklyConsumptionMap is populated', () => { const rt = makeRt('rt1', 'Dev', 1) From fb7f54ba67f6cc2e5a201bd2a826932cb5322a8a Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Wed, 13 May 2026 09:00:01 +1000 Subject: [PATCH 10/11] Fix resource profile cache authority --- server/src/lib/capacityPlanExit.ts | 22 +- server/src/routes/resourceProfile.ts | 38 ++- server/src/routes/resourceTypes.ts | 143 ++++---- server/src/test/resourceProfile.test.ts | 428 ++++++++++++++++++++++++ server/src/test/resourceTypes.test.ts | 228 +++++++++++-- server/src/test/setup.ts | 4 +- 6 files changed, 767 insertions(+), 96 deletions(-) diff --git a/server/src/lib/capacityPlanExit.ts b/server/src/lib/capacityPlanExit.ts index 4a857b9c..d082f8ed 100644 --- a/server/src/lib/capacityPlanExit.ts +++ b/server/src/lib/capacityPlanExit.ts @@ -1,7 +1,9 @@ import { prisma } from './prisma.js' -export async function exitCapacityPlanForManualScheduling(resourceTypeId: string) { - await prisma.resourceType.update({ +type CapacityPlanExitClient = Pick + +async function performCapacityPlanExit(db: CapacityPlanExitClient, resourceTypeId: string) { + await db.resourceType.update({ where: { id: resourceTypeId }, data: { allocationMode: 'TIMELINE', @@ -11,7 +13,7 @@ export async function exitCapacityPlanForManualScheduling(resourceTypeId: string }, }) - await prisma.namedResource.updateMany({ + await db.namedResource.updateMany({ where: { resourceTypeId, allocationMode: 'CAPACITY_PLAN', @@ -27,3 +29,17 @@ export async function exitCapacityPlanForManualScheduling(resourceTypeId: string }, }) } + +export async function exitCapacityPlanForManualScheduling( + resourceTypeId: string, + db?: CapacityPlanExitClient, +) { + if (db) { + await performCapacityPlanExit(db, resourceTypeId) + return + } + + await prisma.$transaction(async tx => { + await performCapacityPlanExit(tx, resourceTypeId) + }) +} diff --git a/server/src/routes/resourceProfile.ts b/server/src/routes/resourceProfile.ts index 2eb16211..e504a6ab 100644 --- a/server/src/routes/resourceProfile.ts +++ b/server/src/routes/resourceProfile.ts @@ -16,6 +16,7 @@ router.use(authenticate) const CATEGORY_ORDER: ResourceCategory[] = ['ENGINEERING', 'GOVERNANCE', 'PROJECT_MANAGEMENT'] const round2 = (value: number) => Math.round(value * 100) / 100 +const weeklyDemandKey = (week: number, resourceTypeName: string) => `${week}|${resourceTypeName}` router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { const projectId = req.params.projectId as string @@ -210,11 +211,11 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { } } - const weeklyDemandMap = new Map() + const fallbackWeeklyDemandMap = new Map() const addWeeklyDemand = (week: number, resourceTypeName: string, demandDays: number) => { if (!Number.isFinite(demandDays) || demandDays <= 0) return - const key = `${week}|${resourceTypeName}` - weeklyDemandMap.set(key, round2((weeklyDemandMap.get(key) ?? 0) + demandDays)) + const key = weeklyDemandKey(week, resourceTypeName) + fallbackWeeklyDemandMap.set(key, round2((fallbackWeeklyDemandMap.get(key) ?? 0) + demandDays)) } for (const epic of project.epics) { @@ -252,14 +253,39 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { } } + const weeklyDemandMap = new Map(fallbackWeeklyDemandMap) + if (project.weeklyDemandCache && Object.keys(project.weeklyDemandCache as Record).length > 0) { + const cachedResourceTypes = new Set() + let globalCachedMaxWeek = Number.NEGATIVE_INFINITY + + for (const [key, demandDays] of Object.entries(project.weeklyDemandCache as Record)) { + const separatorIdx = key.lastIndexOf('|') + if (separatorIdx === -1) continue + const resourceTypeName = key.substring(0, separatorIdx) + const week = Number(key.substring(separatorIdx + 1)) + if (!Number.isFinite(week) || !Number.isFinite(demandDays)) continue + cachedResourceTypes.add(resourceTypeName) + globalCachedMaxWeek = Math.max(globalCachedMaxWeek, week) + } + + for (const key of Array.from(weeklyDemandMap.keys())) { + const separatorIdx = key.indexOf('|') + if (separatorIdx === -1) continue + const week = Number(key.substring(0, separatorIdx)) + const resourceTypeName = key.substring(separatorIdx + 1) + if (cachedResourceTypes.has(resourceTypeName) && week <= globalCachedMaxWeek) { + weeklyDemandMap.delete(key) + } + } + for (const [key, demandDays] of Object.entries(project.weeklyDemandCache as Record)) { const separatorIdx = key.lastIndexOf('|') if (separatorIdx === -1) continue const resourceTypeName = key.substring(0, separatorIdx) const week = Number(key.substring(separatorIdx + 1)) if (!Number.isFinite(week) || !Number.isFinite(demandDays) || demandDays <= 0) continue - weeklyDemandMap.set(`${week}|${resourceTypeName}`, round2(demandDays)) + weeklyDemandMap.set(weeklyDemandKey(week, resourceTypeName), round2(demandDays)) } } @@ -270,7 +296,7 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { resourceTypeName: key.substring(separatorIdx + 1), demandDays, } - }) + }).filter(row => row.demandDays > 0) const namedResourceAssignments = deriveNamedResourceAssignments({ resourceTypes: project.resourceTypes, @@ -399,7 +425,7 @@ router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { // Compute per-NR allocated days namedResourcesOutput = namedResourcesSource.map(nr => { const actualNamedResource = actualNamedResourcesForType - .find(actual => actual.id === nr.id || actual.name === nr.name) + .find(actual => actual.id === nr.id) const nrMode = (nr.allocationMode as AllocationMode) ?? 'EFFORT' const nrPercent = nr.allocationPercent ?? 100 let nrAllocatedDays: number diff --git a/server/src/routes/resourceTypes.ts b/server/src/routes/resourceTypes.ts index 39c6f352..cccc99a2 100644 --- a/server/src/routes/resourceTypes.ts +++ b/server/src/routes/resourceTypes.ts @@ -94,27 +94,32 @@ router.put('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { data.allocationEndWeek = null } - const rt = await prisma.resourceType.update({ - where: { id: req.params.id as string }, - data, - }) - if (shouldExitCapacityPlan) { - await prisma.namedResource.updateMany({ - where: { - resourceTypeId: existing.id, - allocationMode: 'CAPACITY_PLAN', - }, - data: { - allocationMode: 'TIMELINE', - allocationPercent: 100, - allocationStartWeek: null, - allocationEndWeek: null, - allocationPct: 100, - startWeek: null, - endWeek: null, - }, + const rt = await prisma.$transaction(async tx => { + const updated = await tx.resourceType.update({ + where: { id: req.params.id as string }, + data, }) - } + + if (shouldExitCapacityPlan) { + await tx.namedResource.updateMany({ + where: { + resourceTypeId: existing.id, + allocationMode: 'CAPACITY_PLAN', + }, + data: { + allocationMode: 'TIMELINE', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + allocationPct: 100, + startWeek: null, + endWeek: null, + }, + }) + } + + return updated + }) res.json(rt) })) @@ -136,55 +141,67 @@ router.patch('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { const defaultAllocationStartWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationStartWeek ?? null) const defaultAllocationEndWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationEndWeek ?? null) - if (rt.allocationMode === 'CAPACITY_PLAN') { - await exitCapacityPlanForManualScheduling(rt.id) - } + const { updated, warnings } = await prisma.$transaction(async tx => { + if (rt.allocationMode === 'CAPACITY_PLAN') { + await exitCapacityPlanForManualScheduling(rt.id, tx) + } - const currentNRs = await prisma.namedResource.findMany({ - where: { resourceTypeId: rt.id }, - orderBy: { createdAt: 'asc' }, - }) - const currentCount = currentNRs.length - const warnings: string[] = [] + const currentNRs = await tx.namedResource.findMany({ + where: { resourceTypeId: rt.id }, + orderBy: { createdAt: 'asc' }, + }) + const currentCount = currentNRs.length + const nextWarnings: string[] = [] + + if (count > currentCount) { + // Add new anonymous named resources for each new slot + for (let n = currentCount + 1; n <= count; n++) { + await tx.namedResource.create({ + data: { + name: `${rt.name} ${n}`, + resourceTypeId: rt.id, + allocationPct: 100, + ...(defaultAllocationMode !== 'EFFORT' && { + allocationMode: defaultAllocationMode, + allocationPercent: defaultAllocationPercent, + allocationStartWeek: defaultAllocationStartWeek, + allocationEndWeek: defaultAllocationEndWeek, + }), + }, + }) + } - if (count > currentCount) { - // Add new anonymous named resources for each new slot - for (let n = currentCount + 1; n <= count; n++) { - await prisma.namedResource.create({ - data: { - name: `${rt.name} ${n}`, - resourceTypeId: rt.id, - allocationPct: 100, - ...(defaultAllocationMode !== 'EFFORT' && { - allocationMode: defaultAllocationMode, - allocationPercent: defaultAllocationPercent, - allocationStartWeek: defaultAllocationStartWeek, - allocationEndWeek: defaultAllocationEndWeek, - }), - }, - }) + return { + updated: await tx.resourceType.update({ where: { id: rt.id }, data: { count } }), + warnings: nextWarnings, + } } - } else if (count < currentCount) { - // Remove last N named resources (highest createdAt) if they have no custom settings - const toConsider = [...currentNRs].reverse().slice(0, currentCount - count) - let removed = 0 - for (const nr of toConsider) { - if (nr.startWeek !== null || nr.endWeek !== null || nr.allocationPct !== 100) { - warnings.push(`Skipped removal of "${nr.name}" — has custom settings`) - continue + + if (count < currentCount) { + // Remove last N named resources (highest createdAt) if they have no custom settings + const toConsider = [...currentNRs].reverse().slice(0, currentCount - count) + let removed = 0 + for (const nr of toConsider) { + if (nr.startWeek !== null || nr.endWeek !== null || nr.allocationPct !== 100) { + nextWarnings.push(`Skipped removal of "${nr.name}" — has custom settings`) + continue + } + await tx.namedResource.delete({ where: { id: nr.id } }) + removed++ + } + + const actualCount = currentCount - removed + return { + updated: await tx.resourceType.update({ where: { id: rt.id }, data: { count: actualCount } }), + warnings: nextWarnings, } - await prisma.namedResource.delete({ where: { id: nr.id } }) - removed++ } - // Recompute actual count after removals - const actualCount = currentCount - removed - await prisma.resourceType.update({ where: { id: rt.id }, data: { count: actualCount } }) - const updated = await prisma.resourceType.findUnique({ where: { id: rt.id } }) - res.json({ ...updated, warnings: warnings.length > 0 ? warnings : undefined }) - return - } - const updated = await prisma.resourceType.update({ where: { id: rt.id }, data: { count } }) + return { + updated: await tx.resourceType.update({ where: { id: rt.id }, data: { count } }), + warnings: nextWarnings, + } + }) res.json({ ...updated, warnings: warnings.length > 0 ? warnings : undefined }) })) diff --git a/server/src/test/resourceProfile.test.ts b/server/src/test/resourceProfile.test.ts index ecb0d7d7..7680651b 100644 --- a/server/src/test/resourceProfile.test.ts +++ b/server/src/test/resourceProfile.test.ts @@ -498,4 +498,432 @@ describe('GET /api/projects/:projectId/resource-profile', () => { }), ]) }) + + it('treats cached demand as authoritative for the same resource type within the cached horizon', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: 'proj-1', + ownerId: userId, + hoursPerDay: 8, + bufferWeeks: 0, + onboardingWeeks: 0, + weeklyDemandCache: { + 'Developer|0': 5, + 'Developer|2': 5, + }, + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-dev', + name: 'Taylor', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + ], + epics: [ + { + id: 'epic-1', + name: 'Delivery', + order: 0, + isActive: true, + features: [ + { + id: 'feat-1', + name: 'Build', + order: 0, + isActive: true, + userStories: [ + { + id: 'story-1', + name: 'Implement', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + hoursEffort: 120, + durationDays: 15, + resourceTypeId: 'rt-dev', + }, + ], + }, + ], + }, + ], + }, + ], + overheads: [], + timelineEntries: [ + { featureId: 'feat-1', startWeek: 0, durationWeeks: 3 }, + ], + storyTimelineEntries: [], + capacityPlans: [], + } as any) + + const res = await request(app) + .get('/api/projects/proj-1/resource-profile') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const devRow = res.body.resourceRows.find((row: any) => row.resourceTypeId === 'rt-dev') + expect(devRow.namedResources).toEqual([ + expect.objectContaining({ + id: 'nr-dev', + actualAllocatedDays: 10, + actualAllocatedWeeks: [ + expect.objectContaining({ week: 0, days: 5 }), + expect.objectContaining({ week: 2, days: 5 }), + ], + }), + ]) + expect(devRow.namedResources[0].actualAllocatedWeeks.map((week: any) => week.week)).toEqual([0, 2]) + }) + + it('keeps fallback demand for resource types that are absent from cache', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: 'proj-1', + ownerId: userId, + hoursPerDay: 8, + bufferWeeks: 0, + onboardingWeeks: 0, + weeklyDemandCache: { + 'Developer|0': 5, + }, + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-dev', + name: 'Taylor', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + { + id: 'rt-qa', + name: 'QA', + category: 'ENGINEERING', + count: 1, + hoursPerDay: 8, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-qa', + name: 'Morgan', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + ], + epics: [ + { + id: 'epic-1', + name: 'Delivery', + order: 0, + isActive: true, + features: [ + { + id: 'feat-1', + name: 'Build', + order: 0, + isActive: true, + userStories: [ + { + id: 'story-1', + name: 'Implement', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + hoursEffort: 40, + durationDays: 5, + resourceTypeId: 'rt-dev', + }, + { + id: 'task-2', + hoursEffort: 40, + durationDays: 5, + resourceTypeId: 'rt-qa', + }, + ], + }, + ], + }, + ], + }, + ], + overheads: [], + timelineEntries: [ + { featureId: 'feat-1', startWeek: 0, durationWeeks: 1 }, + ], + storyTimelineEntries: [], + capacityPlans: [], + } as any) + + const res = await request(app) + .get('/api/projects/proj-1/resource-profile') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const qaRow = res.body.resourceRows.find((row: any) => row.resourceTypeId === 'rt-qa') + expect(qaRow.namedResources).toEqual([ + expect.objectContaining({ + id: 'nr-qa', + actualAllocatedDays: 5, + actualAllocatedWeeks: [ + expect.objectContaining({ week: 0, days: 5 }), + ], + }), + ]) + }) + + it('does not cross-bind actual allocations when named resources share the same name', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: 'proj-1', + ownerId: userId, + hoursPerDay: 8, + bufferWeeks: 0, + onboardingWeeks: 0, + weeklyDemandCache: { + 'Developer|0': 5, + }, + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + category: 'ENGINEERING', + count: 2, + hoursPerDay: 8, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-1', + name: 'Alex', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + { + id: 'nr-2', + name: 'Alex', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + ], + epics: [ + { + id: 'epic-1', + name: 'Delivery', + order: 0, + isActive: true, + features: [ + { + id: 'feat-1', + name: 'Build', + order: 0, + isActive: true, + userStories: [ + { + id: 'story-1', + name: 'Implement', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + hoursEffort: 40, + durationDays: 5, + resourceTypeId: 'rt-dev', + }, + ], + }, + ], + }, + ], + }, + ], + overheads: [], + timelineEntries: [ + { featureId: 'feat-1', startWeek: 0, durationWeeks: 1 }, + ], + storyTimelineEntries: [], + capacityPlans: [], + } as any) + + const res = await request(app) + .get('/api/projects/proj-1/resource-profile') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const devRow = res.body.resourceRows.find((row: any) => row.resourceTypeId === 'rt-dev') + const byId = Object.fromEntries(devRow.namedResources.map((nr: any) => [nr.id, nr])) + expect(byId['nr-1']).toMatchObject({ + name: 'Alex', + actualAllocatedDays: 5, + }) + expect(byId['nr-2']).toMatchObject({ + name: 'Alex', + actualAllocatedDays: 0, + actualAllocatedWeeks: [], + }) + }) + + it('still appends synthetic actual assignments that have no persisted named resource', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: 'proj-1', + ownerId: userId, + hoursPerDay: 8, + bufferWeeks: 0, + onboardingWeeks: 0, + weeklyDemandCache: { + 'Developer|0': 10, + }, + resourceTypes: [ + { + id: 'rt-dev', + name: 'Developer', + category: 'ENGINEERING', + count: 2, + hoursPerDay: 8, + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + dayRate: null, + globalType: null, + namedResources: [ + { + id: 'nr-1', + name: 'Taylor', + allocationMode: 'EFFORT', + allocationPercent: 100, + allocationStartWeek: null, + allocationEndWeek: null, + startWeek: null, + endWeek: null, + }, + ], + }, + ], + epics: [ + { + id: 'epic-1', + name: 'Delivery', + order: 0, + isActive: true, + features: [ + { + id: 'feat-1', + name: 'Build', + order: 0, + isActive: true, + userStories: [ + { + id: 'story-1', + name: 'Implement', + order: 0, + isActive: true, + tasks: [ + { + id: 'task-1', + hoursEffort: 80, + durationDays: 10, + resourceTypeId: 'rt-dev', + }, + ], + }, + ], + }, + ], + }, + ], + overheads: [], + timelineEntries: [ + { featureId: 'feat-1', startWeek: 0, durationWeeks: 1 }, + ], + storyTimelineEntries: [], + capacityPlans: [], + } as any) + + const res = await request(app) + .get('/api/projects/proj-1/resource-profile') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + + const devRow = res.body.resourceRows.find((row: any) => row.resourceTypeId === 'rt-dev') + expect(devRow.namedResources).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'nr-1', + actualAllocatedDays: 5, + }), + expect.objectContaining({ + id: 'rt-dev-synthetic-2', + name: 'Developer 2', + synthetic: true, + actualAllocatedDays: 5, + }), + ])) + }) }) diff --git a/server/src/test/resourceTypes.test.ts b/server/src/test/resourceTypes.test.ts index 02903613..f62bf985 100644 --- a/server/src/test/resourceTypes.test.ts +++ b/server/src/test/resourceTypes.test.ts @@ -25,12 +25,19 @@ describe('resource type manual scheduling regression', () => { allocationStartWeek: 4, allocationEndWeek: 8, } as never) - vi.mocked(prisma.resourceType.update).mockResolvedValue({ - id: 'rt-1', - count: 2, - allocationMode: 'TIMELINE', - } as never) - vi.mocked(prisma.namedResource.updateMany).mockResolvedValue({ count: 2 } as never) + const tx = { + resourceType: { + update: vi.fn().mockResolvedValue({ + id: 'rt-1', + count: 2, + allocationMode: 'TIMELINE', + }), + }, + namedResource: { + updateMany: vi.fn().mockResolvedValue({ count: 2 }), + }, + } + vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(tx)) const res = await request(app) .put('/api/projects/proj-1/resource-types/rt-1') @@ -38,7 +45,7 @@ describe('resource type manual scheduling regression', () => { .send({ count: 2 }) expect(res.status).toBe(200) - expect(prisma.resourceType.update).toHaveBeenCalledWith({ + expect(tx.resourceType.update).toHaveBeenCalledWith({ where: { id: 'rt-1' }, data: { count: 2, @@ -48,7 +55,7 @@ describe('resource type manual scheduling regression', () => { allocationEndWeek: null, }, }) - expect(prisma.namedResource.updateMany).toHaveBeenCalledWith({ + expect(tx.namedResource.updateMany).toHaveBeenCalledWith({ where: { resourceTypeId: 'rt-1', allocationMode: 'CAPACITY_PLAN', @@ -72,12 +79,20 @@ describe('resource type manual scheduling regression', () => { projectId: 'proj-1', allocationMode: 'CAPACITY_PLAN', } as never) - vi.mocked(prisma.resourceType.update).mockResolvedValue({ - id: 'rt-1', - count: 2, - allocationMode: 'FULL_PROJECT', - allocationPercent: 50, - } as never) + const tx = { + resourceType: { + update: vi.fn().mockResolvedValue({ + id: 'rt-1', + count: 2, + allocationMode: 'FULL_PROJECT', + allocationPercent: 50, + }), + }, + namedResource: { + updateMany: vi.fn(), + }, + } + vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(tx)) const res = await request(app) .put('/api/projects/proj-1/resource-types/rt-1') @@ -85,7 +100,7 @@ describe('resource type manual scheduling regression', () => { .send({ count: 2, allocationMode: 'FULL_PROJECT', allocationPercent: 50 }) expect(res.status).toBe(200) - expect(prisma.resourceType.update).toHaveBeenCalledWith({ + expect(tx.resourceType.update).toHaveBeenCalledWith({ where: { id: 'rt-1' }, data: { count: 2, @@ -93,7 +108,169 @@ describe('resource type manual scheduling regression', () => { allocationPercent: 50, }, }) - expect(prisma.namedResource.updateMany).not.toHaveBeenCalled() + expect(tx.namedResource.updateMany).not.toHaveBeenCalled() + }) + + it('rolls back PUT capacity-plan exit when named-resource updates fail', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.findFirst).mockResolvedValue({ + id: 'rt-1', + projectId: 'proj-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + } as never) + const committedState = { + resourceType: { + id: 'rt-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + count: 1, + }, + namedResources: [ + { + id: 'nr-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + }, + ], + } + vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => { + const draftState = JSON.parse(JSON.stringify(committedState)) + const tx = { + resourceType: { + update: vi.fn(async ({ data }: any) => { + Object.assign(draftState.resourceType, data) + return draftState.resourceType + }), + }, + namedResource: { + updateMany: vi.fn(async () => { + throw new Error('named-resource update failed') + }), + }, + } + + const result = await fn(tx) + Object.assign(committedState.resourceType, draftState.resourceType) + committedState.namedResources.splice(0, committedState.namedResources.length, ...draftState.namedResources) + return result + }) + + const res = await request(app) + .put('/api/projects/proj-1/resource-types/rt-1') + .set('Authorization', authHeader) + .send({ count: 2 }) + + expect(res.status).toBe(500) + expect(committedState.resourceType).toMatchObject({ + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + count: 1, + }) + expect(committedState.namedResources).toEqual([ + expect.objectContaining({ + id: 'nr-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + }), + ]) + }) + + it('rolls back PATCH count sync when capacity-plan exit cannot complete the full operation', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.findFirst).mockResolvedValue({ + id: 'rt-1', + projectId: 'proj-1', + name: 'Developer', + count: 1, + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + } as never) + const committedState = { + resourceType: { + id: 'rt-1', + count: 1, + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + }, + namedResources: [ + { + id: 'nr-1', + name: 'Developer 1', + resourceTypeId: 'rt-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationPct: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + startWeek: 4, + endWeek: 8, + }, + ], + } + vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => { + const draftState = JSON.parse(JSON.stringify(committedState)) + const tx = { + resourceType: { + update: vi.fn(async ({ data }: any) => { + Object.assign(draftState.resourceType, data) + return draftState.resourceType + }), + }, + namedResource: { + findMany: vi.fn(async () => draftState.namedResources), + updateMany: vi.fn(async ({ data }: any) => { + draftState.namedResources = draftState.namedResources.map((nr: any) => ({ ...nr, ...data })) + return { count: draftState.namedResources.length } + }), + create: vi.fn(async () => { + throw new Error('count sync failed') + }), + delete: vi.fn(), + }, + } + + const result = await fn(tx) + Object.assign(committedState.resourceType, draftState.resourceType) + committedState.namedResources.splice(0, committedState.namedResources.length, ...draftState.namedResources) + return result + }) + + const res = await request(app) + .patch('/api/projects/proj-1/resource-types/rt-1') + .set('Authorization', authHeader) + .send({ count: 2 }) + + expect(res.status).toBe(500) + expect(committedState.resourceType).toMatchObject({ + count: 1, + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + }) + expect(committedState.namedResources).toEqual([ + expect.objectContaining({ + id: 'nr-1', + allocationMode: 'CAPACITY_PLAN', + allocationPercent: 25, + allocationPct: 25, + allocationStartWeek: 4, + allocationEndWeek: 8, + startWeek: 4, + endWeek: 8, + }), + ]) }) it('exits CAPACITY_PLAN when a person is manually removed from Resource Profile', async () => { @@ -111,10 +288,17 @@ describe('resource type manual scheduling regression', () => { id: 'nr-2', resourceTypeId: 'rt-1', } as never) - vi.mocked(prisma.namedResource.updateMany).mockResolvedValue({ count: 2 } as never) + const exitTx = { + resourceType: { + update: vi.fn().mockResolvedValue({ id: 'rt-1', allocationMode: 'TIMELINE' }), + }, + namedResource: { + updateMany: vi.fn().mockResolvedValue({ count: 2 }), + }, + } + vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(exitTx)) vi.mocked(prisma.resourceType.update) - .mockResolvedValueOnce({ id: 'rt-1', allocationMode: 'TIMELINE' } as never) - .mockResolvedValueOnce({ id: 'rt-1', count: 1, allocationMode: 'TIMELINE' } as never) + .mockResolvedValue({ id: 'rt-1', count: 1, allocationMode: 'TIMELINE' } as never) vi.mocked(prisma.namedResource.delete).mockResolvedValue({} as never) vi.mocked(prisma.namedResource.count).mockResolvedValue(1) @@ -123,7 +307,7 @@ describe('resource type manual scheduling regression', () => { .set('Authorization', authHeader) expect(res.status).toBe(204) - expect(prisma.resourceType.update).toHaveBeenNthCalledWith(1, { + expect(exitTx.resourceType.update).toHaveBeenCalledWith({ where: { id: 'rt-1' }, data: { allocationMode: 'TIMELINE', @@ -132,7 +316,7 @@ describe('resource type manual scheduling regression', () => { allocationEndWeek: null, }, }) - expect(prisma.namedResource.updateMany).toHaveBeenCalledWith({ + expect(exitTx.namedResource.updateMany).toHaveBeenCalledWith({ where: { resourceTypeId: 'rt-1', allocationMode: 'CAPACITY_PLAN', @@ -147,7 +331,7 @@ describe('resource type manual scheduling regression', () => { endWeek: null, }, }) - expect(prisma.resourceType.update).toHaveBeenNthCalledWith(2, { + expect(prisma.resourceType.update).toHaveBeenCalledWith({ where: { id: 'rt-1' }, data: { count: 1 }, }) diff --git a/server/src/test/setup.ts b/server/src/test/setup.ts index e77728cb..e30db825 100644 --- a/server/src/test/setup.ts +++ b/server/src/test/setup.ts @@ -58,8 +58,8 @@ vi.mock('../lib/prisma.js', () => ({ userStory: { create: vi.fn().mockResolvedValue({ id: 'story-id' }) }, task: { create: vi.fn() }, project: { update: vi.fn() }, - resourceType: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn(), upsert: vi.fn() }, - namedResource: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), updateMany: vi.fn(), create: vi.fn(), upsert: vi.fn() }, + resourceType: { findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn(), update: vi.fn(), create: vi.fn(), upsert: vi.fn() }, + namedResource: { findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn(), upsert: vi.fn(), delete: vi.fn() }, timelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, storyTimelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, epicDependency: { deleteMany: vi.fn(), createMany: vi.fn() }, From 203ee6303b176cefd4a8ff60948c1094c91e19ef Mon Sep 17 00:00:00 2001 From: NickMonrad <113862039+NickMonrad@users.noreply.github.com> Date: Fri, 29 May 2026 10:30:39 +1000 Subject: [PATCH 11/11] fix: harden access control and atomicity from code review (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resourceTypes DELETE: scope deleteMany to projectId and 404 when no row matches, preventing cross-tenant resource type deletion (IDOR) - timeline PUT /:featureId: verify the feature belongs to the owned project before upserting, preventing cross-tenant timeline overwrites (IDOR) - namedResources POST/DELETE: wrap capacity-plan exit, create/delete and count re-sync in a single transaction to avoid count desync under concurrent requests Adds cross-tenant 404 tests for the resourceTypes DELETE and timeline PUT routes and updates mocks for the transactional named-resource flow. Note: the IPv6 rate-limit hardening (ipKeyGenerator) is deferred — the installed express-rate-limit@7.5.1 does not export that helper. Co-authored-by: NickMonrad Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/src/routes/namedResources.ts | 66 +++++++++++++++------------ server/src/routes/resourceTypes.ts | 3 +- server/src/routes/timeline.ts | 3 ++ server/src/test/resourceTypes.test.ts | 40 +++++++++++++++- server/src/test/setup.ts | 4 +- server/src/test/timeline.test.ts | 15 ++++++ 6 files changed, 97 insertions(+), 34 deletions(-) diff --git a/server/src/routes/namedResources.ts b/server/src/routes/namedResources.ts index 77002e20..280e482d 100644 --- a/server/src/routes/namedResources.ts +++ b/server/src/routes/namedResources.ts @@ -45,10 +45,6 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { const rt = await verifyResourceType(rtId, projectId) if (!rt) { res.status(404).json({ error: 'Resource type not found' }); return } - if (rt.allocationMode === 'CAPACITY_PLAN') { - await exitCapacityPlanForManualScheduling(rt.id) - } - const { name: rawName, startWeek, endWeek, allocationPct, pricingModel } = req.body // Auto-generate a numbered name if none provided or generic @@ -75,27 +71,35 @@ router.post('/', asyncHandler(async (req: AuthRequest, res: Response) => { const rtAllocationEndWeek = rt.allocationMode === 'CAPACITY_PLAN' ? null : (rt.allocationEndWeek ?? null) const inheritAllocation = rtAllocationMode !== 'EFFORT' - const resource = await prisma.namedResource.create({ - data: { - name, - resourceTypeId: rtId, - ...(startWeek !== undefined && { startWeek }), - ...(endWeek !== undefined && { endWeek }), - ...(allocationPct !== undefined && { allocationPct }), - ...(pricingModel !== undefined && { pricingModel }), - ...(inheritAllocation && { - allocationMode: rtAllocationMode, - allocationPercent: rtAllocationPercent, - allocationStartWeek: rtAllocationStartWeek, - allocationEndWeek: rtAllocationEndWeek, - }), - }, + const resource = await prisma.$transaction(async tx => { + if (rt.allocationMode === 'CAPACITY_PLAN') { + await exitCapacityPlanForManualScheduling(rt.id, tx) + } + + const created = await tx.namedResource.create({ + data: { + name, + resourceTypeId: rtId, + ...(startWeek !== undefined && { startWeek }), + ...(endWeek !== undefined && { endWeek }), + ...(allocationPct !== undefined && { allocationPct }), + ...(pricingModel !== undefined && { pricingModel }), + ...(inheritAllocation && { + allocationMode: rtAllocationMode, + allocationPercent: rtAllocationPercent, + allocationStartWeek: rtAllocationStartWeek, + allocationEndWeek: rtAllocationEndWeek, + }), + }, + }) + + // Sync resource type count to match total named resources + const total = await tx.namedResource.count({ where: { resourceTypeId: rtId } }) + await tx.resourceType.update({ where: { id: rtId }, data: { count: total } }) + + return created }) - // Sync resource type count to match total named resources - const total = await prisma.namedResource.count({ where: { resourceTypeId: rtId } }) - await prisma.resourceType.update({ where: { id: rtId }, data: { count: total } }) - res.status(201).json(resource) })) @@ -173,15 +177,17 @@ router.delete('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { const existing = await prisma.namedResource.findFirst({ where: { id, resourceTypeId: rtId } }) if (!existing) { res.status(404).json({ error: 'Named resource not found' }); return } - if (rt.allocationMode === 'CAPACITY_PLAN') { - await exitCapacityPlanForManualScheduling(rt.id) - } + await prisma.$transaction(async tx => { + if (rt.allocationMode === 'CAPACITY_PLAN') { + await exitCapacityPlanForManualScheduling(rt.id, tx) + } - await prisma.namedResource.delete({ where: { id } }) + await tx.namedResource.delete({ where: { id } }) - // Sync resource type count (can reach 0 when all named resources are deleted) - const total = await prisma.namedResource.count({ where: { resourceTypeId: rtId } }) - await prisma.resourceType.update({ where: { id: rtId }, data: { count: total } }) + // Sync resource type count (can reach 0 when all named resources are deleted) + const total = await tx.namedResource.count({ where: { resourceTypeId: rtId } }) + await tx.resourceType.update({ where: { id: rtId }, data: { count: total } }) + }) res.status(204).send() })) diff --git a/server/src/routes/resourceTypes.ts b/server/src/routes/resourceTypes.ts index cccc99a2..f3e4dd6e 100644 --- a/server/src/routes/resourceTypes.ts +++ b/server/src/routes/resourceTypes.ts @@ -209,7 +209,8 @@ router.patch('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { router.delete('/:id', asyncHandler(async (req: AuthRequest, res: Response) => { const project = await ownedProject(req.params.projectId as string, req.userId!) if (!project) { res.status(404).json({ error: 'Project not found' }); return } - await prisma.resourceType.delete({ where: { id: req.params.id as string } }) + const result = await prisma.resourceType.deleteMany({ where: { id: req.params.id as string, projectId: req.params.projectId as string } }) + if (result.count === 0) { res.status(404).json({ error: 'Resource type not found' }); return } res.json({ message: 'Deleted' }) })) diff --git a/server/src/routes/timeline.ts b/server/src/routes/timeline.ts index 4ef479c5..cd077d4b 100644 --- a/server/src/routes/timeline.ts +++ b/server/src/routes/timeline.ts @@ -960,6 +960,9 @@ router.put('/:featureId', asyncHandler(async (req: AuthRequest, res: Response) = } const featureId = req.params.featureId as string + const feature = await prisma.feature.findFirst({ where: { id: featureId, epic: { projectId: project.id } } }) + if (!feature) { res.status(404).json({ error: 'Feature not found' }); return } + const entry = await prisma.timelineEntry.upsert({ where: { featureId }, create: { projectId: project.id, featureId, startWeek, durationWeeks, isManual: true }, diff --git a/server/src/test/resourceTypes.test.ts b/server/src/test/resourceTypes.test.ts index f62bf985..704f741b 100644 --- a/server/src/test/resourceTypes.test.ts +++ b/server/src/test/resourceTypes.test.ts @@ -294,6 +294,8 @@ describe('resource type manual scheduling regression', () => { }, namedResource: { updateMany: vi.fn().mockResolvedValue({ count: 2 }), + delete: vi.fn().mockResolvedValue({}), + count: vi.fn().mockResolvedValue(1), }, } vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(exitTx)) @@ -331,9 +333,45 @@ describe('resource type manual scheduling regression', () => { endWeek: null, }, }) - expect(prisma.resourceType.update).toHaveBeenCalledWith({ + expect(exitTx.namedResource.delete).toHaveBeenCalledWith({ where: { id: 'nr-2' } }) + expect(exitTx.resourceType.update).toHaveBeenCalledWith({ where: { id: 'rt-1' }, data: { count: 1 }, }) }) }) + +describe('DELETE /api/projects/:projectId/resource-types/:id', () => { + it('deletes a resource type scoped to the project', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.deleteMany).mockResolvedValue({ count: 1 } as never) + + const res = await request(app) + .delete('/api/projects/proj-1/resource-types/rt-1') + .set('Authorization', authHeader) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ message: 'Deleted' }) + // Delete must be scoped to the project, not a bare primary-key delete + expect(prisma.resourceType.deleteMany).toHaveBeenCalledWith({ + where: { id: 'rt-1', projectId: 'proj-1' }, + }) + }) + + it('returns 404 when the resource type belongs to another project (cross-tenant delete)', async () => { + // Caller owns proj-1, but rt-99 lives in another tenant's project, so the + // project-scoped deleteMany affects 0 rows. + vi.mocked(prisma.project.findFirst).mockResolvedValue({ id: 'proj-1', ownerId: userId } as never) + vi.mocked(prisma.resourceType.deleteMany).mockResolvedValue({ count: 0 } as never) + + const res = await request(app) + .delete('/api/projects/proj-1/resource-types/rt-99') + .set('Authorization', authHeader) + + expect(res.status).toBe(404) + expect(res.body).toEqual({ error: 'Resource type not found' }) + expect(prisma.resourceType.deleteMany).toHaveBeenCalledWith({ + where: { id: 'rt-99', projectId: 'proj-1' }, + }) + }) +}) diff --git a/server/src/test/setup.ts b/server/src/test/setup.ts index e30db825..84d6fd09 100644 --- a/server/src/test/setup.ts +++ b/server/src/test/setup.ts @@ -19,7 +19,7 @@ vi.mock('../lib/prisma.js', () => ({ feature: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, userStory: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, task: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, - resourceType: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), createMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn() }, + resourceType: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), createMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn() }, globalResourceType: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, featureTemplate: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, templateTask: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), count: vi.fn() }, @@ -59,7 +59,7 @@ vi.mock('../lib/prisma.js', () => ({ task: { create: vi.fn() }, project: { update: vi.fn() }, resourceType: { findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn(), update: vi.fn(), create: vi.fn(), upsert: vi.fn() }, - namedResource: { findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn(), upsert: vi.fn(), delete: vi.fn() }, + namedResource: { findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn(), upsert: vi.fn(), delete: vi.fn(), count: vi.fn() }, timelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, storyTimelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, epicDependency: { deleteMany: vi.fn(), createMany: vi.fn() }, diff --git a/server/src/test/timeline.test.ts b/server/src/test/timeline.test.ts index 3e000b20..29f845e2 100644 --- a/server/src/test/timeline.test.ts +++ b/server/src/test/timeline.test.ts @@ -877,6 +877,7 @@ describe('PUT /api/projects/:projectId/timeline/:featureId', () => { it('overrides a feature startWeek and durationWeeks with isManual=true', async () => { vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject as any) + vi.mocked(prisma.feature.findFirst).mockResolvedValue({ id: 'feat-1', epicId: 'epic-1' } as any) vi.mocked(prisma.timelineEntry.upsert).mockResolvedValue({ id: 'entry-1', projectId: 'proj-1', @@ -898,6 +899,20 @@ describe('PUT /api/projects/:projectId/timeline/:featureId', () => { expect(res.body.isManual).toBe(true) }) + it('returns 404 when the feature belongs to another project (cross-tenant write)', async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject as any) + // Feature ownership scoped to the authorised project — foreign feature not found + vi.mocked(prisma.feature.findFirst).mockResolvedValue(null) + + const res = await request(app) + .put('/api/projects/proj-1/timeline/feat-foreign') + .set('Authorization', authHeader) + .send({ startWeek: 2, durationWeeks: 3 }) + + expect(res.status).toBe(404) + expect(prisma.timelineEntry.upsert).not.toHaveBeenCalled() + }) + it('returns 400 when startWeek or durationWeeks missing', async () => { vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject as any) const res = await request(app)