diff --git a/README.md b/README.md index 5cee216..ccfdaeb 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ Day rates per resource type (global defaults + project overrides) and cost colum | Doc Generator v2 (continued) — redesigned Documents page (generation panel + document grid), Gantt chart section (server-side SVG), sections metadata on document cards, project name in label/filename, HTML preserved in CSV export for round-trip fidelity | #166 | | Quick wins — dark mode description contrast, project settings tax/onboarding fields, backlog feature mode (sequential/parallel) badges, scope toggle pill redesign, CSV import row colouring, unrated resource warning improvements; Gantt PDF sanitize-html fix + landscape page; pin axios to 1.14.0 (supply chain safety) | #220 | | Backlog dependency tracking — EpicDependency schema + API (circular dep detection), epic dep hard constraints in timeline scheduler, epic dep arrows on Timeline Gantt, dep selector UI on backlog epic/feature rows, EpicDependsOn/FeatureDependsOn columns in CSV import/export | #228 | +| Timeline scale toggle (week/month/quarter/year), cross-epic FeatureDependsOn in CSV, TemplateSize column in backlog CSV for template task auto-expand; Year scale (H1/H2 top row, Q1–Q4 bottom row); allocation mode/% + timeline start/end weeks in Resource Counts; Expand All / Collapse All toggle; dep picker search filter; top scrollbar mirror; full-width Gantt layout; named resource per-person T&M histogram; onboarding/buffer zone calc fix; auto-reschedule prevention on resource changes | #231 | --- @@ -233,17 +234,6 @@ Day rates per resource type (global defaults + project overrides) and cost colum | # | Title | |---|---| | [#108](https://github.com/NickMonrad/monrad-estimator/issues/108) | docs: comprehensive functional specification (`docs/FUNCTIONAL_SPEC.md`) | -| [#109](https://github.com/NickMonrad/monrad-estimator/issues/109) | Global Customer entity (name, description, account code, CRM link) + link to projects | -| [#57](https://github.com/NickMonrad/monrad-estimator/issues/57) | Template tasks: assumptions + description fields | -| [#61](https://github.com/NickMonrad/monrad-estimator/issues/61) | Template tasks: percentage-based tasks (% of cumulative totals) | -| [#56](https://github.com/NickMonrad/monrad-estimator/issues/56) | Clone project | -| [#64](https://github.com/NickMonrad/monrad-estimator/issues/64) | Global configuration menu (resource types, templates, overhead defaults) | -| [#23](https://github.com/NickMonrad/monrad-estimator/issues/23) | Global default overheads, inheritable per project | -| [#46](https://github.com/NickMonrad/monrad-estimator/issues/46) | Soft-delete templates with restore | -| [#62](https://github.com/NickMonrad/monrad-estimator/issues/62) | Refactor: flatMap in effort.ts + snapshots.ts | -| [#19](https://github.com/NickMonrad/monrad-estimator/issues/19) | Apply template button — improve discoverability | -| [#69](https://github.com/NickMonrad/monrad-estimator/issues/69) | GST configurable rate per project via Project Settings (ex-GST/inc-GST totals already ship in Resource Profile; rate UI missing) | -| [#229](https://github.com/NickMonrad/monrad-estimator/issues/229) | CSV import: auto-expand template tasks and backfill estimates from template sizing (requires new TemplateSize column) | ### 🚀 Feature ideas | # | Title | diff --git a/client/src/components/timeline/GanttBar.tsx b/client/src/components/timeline/GanttBar.tsx index f1871be..2cf8eb5 100644 --- a/client/src/components/timeline/GanttBar.tsx +++ b/client/src/components/timeline/GanttBar.tsx @@ -1,6 +1,6 @@ import type { TimelineEntry } from '../../types/backlog' import type { GanttRow, StoryTimelineEntry, GanttDraggingState } from '../../hooks/useGanttLayout' -import { COL_W, EPIC_ROW_H, FEAT_ROW_H, STORY_ROW_H } from '../../hooks/useGanttLayout' +import { EPIC_ROW_H, FEAT_ROW_H, STORY_ROW_H } from '../../hooks/useGanttLayout' import { getEpicColour } from '../../lib/epicColours' // --------------------------------------------------------------------------- @@ -16,6 +16,7 @@ interface GanttBarProps { y: number weekOffset: number totalWeeks: number + colW: number dragging: GanttDraggingState | null svgColors: SvgColors weeklyDemand: { week: number; resourceTypeName: string; demandDays: number; capacityDays: number }[] @@ -53,6 +54,7 @@ export default function GanttBar({ y, weekOffset, totalWeeks, + colW, dragging, svgColors, weeklyDemand, @@ -67,12 +69,12 @@ export default function GanttBar({ // ── Epic bar ────────────────────────────────────────────────────────────── if (row.type === 'epic') { const colour = getEpicColour(row.epicIdx) - const barW = (row.maxWeek - row.minWeek) * COL_W + const barW = (row.maxWeek - row.minWeek) * colW if (barW <= 0) return null return ( @@ -107,7 +109,7 @@ 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 barW = Math.max(entry.durationWeeks * COL_W, 4) + const barW = Math.max(entry.durationWeeks * colW, 4) const isOverAllocated = weeklyDemand.some(d => d.week >= entry.startWeek && d.week < entry.startWeek + entry.durationWeeks && @@ -117,7 +119,7 @@ export default function GanttBar({ return ( {isOverAllocated && ( @@ -170,9 +172,9 @@ export default function GanttBar({ return ( {storyEntry.isManual && ( diff --git a/client/src/components/timeline/GanttChart.tsx b/client/src/components/timeline/GanttChart.tsx index f25c140..dcf3ede 100644 --- a/client/src/components/timeline/GanttChart.tsx +++ b/client/src/components/timeline/GanttChart.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import type { TimelineEntry } from '../../types/backlog' import { useIsDark } from '../../hooks/useIsDark' import { useGanttLayout, - COL_W, HEADER_H, + colWForScale, } from '../../hooks/useGanttLayout' import type { + GanttScale, StoryTimelineEntry, FeatureDependency, StoryDependency, @@ -31,6 +32,95 @@ function formatDate(date: Date): string { return `${date.getDate()}/${date.getMonth() + 1}` } +interface GroupBand { label: string; startWeek: number; endWeek: number } + +/** Group totalWeeks into calendar months (or generic Month N if no startDate). */ +function buildMonthGroups(totalWeeks: number, startDate: Date | null): GroupBand[] { + if (!startDate) { + const groups: GroupBand[] = [] + for (let w = 0; w < totalWeeks; w += 4) { + groups.push({ label: `Month ${Math.floor(w / 4) + 1}`, startWeek: w, endWeek: Math.min(w + 4, totalWeeks) }) + } + return groups + } + const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + const groups: GroupBand[] = [] + let currentKey = -1 + let groupStart = 0 + for (let w = 0; w <= totalWeeks; w++) { + const d = addDays(startDate, w * 7) + const key = d.getFullYear() * 100 + d.getMonth() + if (w === totalWeeks || key !== currentKey) { + if (w > 0 && currentKey !== -1) { + const sd = addDays(startDate, groupStart * 7) + groups.push({ label: `${MONTHS[sd.getMonth()]} ${sd.getFullYear()}`, startWeek: groupStart, endWeek: w }) + } + currentKey = key + groupStart = w + } + } + return groups +} + +/** Group totalWeeks into calendar quarters (or generic QN if no startDate). */ +function buildQuarterGroups(totalWeeks: number, startDate: Date | null): GroupBand[] { + if (!startDate) { + const groups: GroupBand[] = [] + for (let w = 0; w < totalWeeks; w += 13) { + groups.push({ label: `Q${Math.floor(w / 13) + 1}`, startWeek: w, endWeek: Math.min(w + 13, totalWeeks) }) + } + return groups + } + const groups: GroupBand[] = [] + let currentKey = -1 + let groupStart = 0 + for (let w = 0; w <= totalWeeks; w++) { + const d = addDays(startDate, w * 7) + const qNum = Math.floor(d.getMonth() / 3) + const key = d.getFullYear() * 10 + qNum + if (w === totalWeeks || key !== currentKey) { + if (w > 0 && currentKey !== -1) { + const qDisplay = (currentKey % 10) + 1 + const year = Math.floor(currentKey / 10) + groups.push({ label: `Q${qDisplay} ${year}`, startWeek: groupStart, endWeek: w }) + } + currentKey = key + groupStart = w + } + } + return groups +} + +/** Group totalWeeks into half-year bands (H1/H2). */ +function buildHalfYearGroups(totalWeeks: number, startDate: Date | null): GroupBand[] { + if (!startDate) { + const groups: GroupBand[] = [] + for (let w = 0; w < totalWeeks; w += 26) { + const half = Math.floor(w / 26) + groups.push({ label: `H${(half % 2) + 1}`, startWeek: w, endWeek: Math.min(w + 26, totalWeeks) }) + } + return groups + } + const groups: GroupBand[] = [] + let currentKey = -1 + let groupStart = 0 + for (let w = 0; w <= totalWeeks; w++) { + const d = addDays(startDate, w * 7) + const half = d.getMonth() < 6 ? 0 : 1 + const key = d.getFullYear() * 10 + half + if (w === totalWeeks || key !== currentKey) { + if (w > 0 && currentKey !== -1) { + const halfNum = (currentKey % 10) + 1 + const year = Math.floor(currentKey / 10) + groups.push({ label: `H${halfNum} ${year}`, startWeek: groupStart, endWeek: w }) + } + currentKey = key + groupStart = w + } + } + return groups +} + // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- @@ -42,6 +132,7 @@ interface GanttChartProps { epicDependencies?: EpicDependency[] totalWeeks: number projectStartDate: Date | null + scale?: GanttScale onDragFeature: (featureId: string, newStartWeek: number) => void onDragStory: (storyId: string, newStartWeek: number) => void onAddFeatureDep: (featureId: string, dependsOnId: string) => void @@ -76,6 +167,7 @@ export default function GanttChart({ epicDependencies = [], totalWeeks, projectStartDate, + scale = 'week', onDragFeature, onDragStory, onMoveEpic, @@ -94,13 +186,37 @@ export default function GanttChart({ weekOffset = 0, bufferWeeks = 0, }: GanttChartProps) { + // Derived column width for the active scale + const colW = colWForScale(scale) const [expandedFeatures, setExpandedFeatures] = useState>(new Set()) const [expandedEpics, setExpandedEpics] = useState>(new Set()) + // Tracks ALL epic IDs ever seen — separate from expandedEpics so "Collapse All" doesn't + // make every epic look "new" on the next entries refetch. + const knownEpicIds = useRef>(new Set()) - // Initialise expandedEpics with all unique epic IDs whenever entries change useEffect(() => { const ids = new Set(entries.map(e => e.epicId)) - setExpandedEpics(ids) + if (knownEpicIds.current.size === 0 && ids.size > 0) { + // First load — expand all and record as known + knownEpicIds.current = new Set(ids) + setExpandedEpics(ids) + } else if (ids.size > 0) { + // Only auto-expand epics that are genuinely new (not seen before) + const newIds: string[] = [] + for (const id of ids) { + if (!knownEpicIds.current.has(id)) { + newIds.push(id) + knownEpicIds.current.add(id) + } + } + if (newIds.length > 0) { + setExpandedEpics(prev => { + const merged = new Set(prev) + for (const id of newIds) merged.add(id) + return merged + }) + } + } }, [entries]) const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null) @@ -157,7 +273,7 @@ export default function GanttChart({ useEffect(() => { function onMouseMove(e: MouseEvent) { if (!dragging) return - const deltaWeeks = (e.clientX - dragging.startX) / COL_W + const deltaWeeks = (e.clientX - dragging.startX) / colW const snapped = Math.max(0, Math.round((dragging.origStart + deltaWeeks) / 0.2) * 0.2) setDragging(d => (d ? { ...d, currentStart: snapped } : null)) } @@ -193,6 +309,24 @@ export default function GanttChart({ // ----------------------------------------------------------------------- // Render // ----------------------------------------------------------------------- + + // Pre-compute month / quarter groups for header rendering + const monthGroups = useMemo( + () => buildMonthGroups(totalWeeks, projectStartDate), + [totalWeeks, projectStartDate], + ) + const quarterGroups = useMemo( + () => buildQuarterGroups(totalWeeks, projectStartDate), + [totalWeeks, projectStartDate], + ) + const halfYearGroups = useMemo( + () => buildHalfYearGroups(totalWeeks, projectStartDate), + [totalWeeks, projectStartDate], + ) + + // Two-row header mid-line Y position + const HEADER_MID = 24 + return (
- + {/* Background fill */} - + {/* Onboarding zone */} {weekOffset > 0 && ( - - - - Onboarding ({weekOffset}w) - - = 28 && ( + <> + + + {weekOffset * colW >= 80 + ? `Onboarding (${weekOffset}w)` + : weekOffset * colW >= 40 + ? `Onbrd` + : `O`} + + + )} + )} @@ -248,33 +391,41 @@ export default function GanttChart({ {/* Buffer zone */} {bufferWeeks > 0 && ( - - - - Buffer ({bufferWeeks}w) - - = 28 && ( + <> + + + {bufferWeeks * colW >= 64 + ? `Buffer (${bufferWeeks}w)` + : bufferWeeks * colW >= 36 + ? `Buf` + : `B`} + + + )} + )} - {/* Week header + vertical grid lines */} - {Array.from({ length: totalWeeks }, (_, i) => ( + {/* ── Week scale header ── */} + {scale === 'week' && Array.from({ length: totalWeeks }, (_, i) => ( - - W{i + 1} {projectStartDate && ( - {formatDate(addDays(projectStartDate, i * 7))} @@ -282,8 +433,139 @@ export default function GanttChart({ ))} + {/* ── Month scale header: top row = month label, bottom row = week-within-month ── */} + {scale === 'month' && ( + <> + {/* Mid-header separator */} + + {monthGroups.map((mg, gi) => { + const groupX = mg.startWeek * colW + const groupW = (mg.endWeek - mg.startWeek) * colW + return ( + + {/* Month group left border (full height) */} + + {/* Month label in top row */} + + {mg.label} + + {/* Week columns within month */} + {Array.from({ length: mg.endWeek - mg.startWeek }, (_, wi) => { + const wx = (mg.startWeek + wi) * colW + return ( + + {wi > 0 && ( + + )} + + {wi + 1} + + + ) + })} + + ) + })} + + )} + + {/* ── Quarter scale header: top row = quarter label, bottom row = month abbrev ── */} + {scale === 'quarter' && ( + <> + {/* Mid-header separator */} + + {/* Quarter group labels and their left-border */} + {quarterGroups.map((qg, qi) => { + const groupX = qg.startWeek * colW + const groupW = (qg.endWeek - qg.startWeek) * colW + return ( + + + + {qg.label} + + + ) + })} + {/* Month bands in bottom row */} + {monthGroups.map((mg, mi) => { + const groupX = mg.startWeek * colW + const groupW = (mg.endWeek - mg.startWeek) * colW + const MONTH_ABBREVS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + const monthAbbrev = projectStartDate + ? MONTH_ABBREVS[addDays(projectStartDate, mg.startWeek * 7).getMonth()] + : `M${mi + 1}` + return ( + + + {/* Week grid lines within month */} + {Array.from({ length: mg.endWeek - mg.startWeek }, (_, wi) => wi > 0 && ( + + ))} + + {monthAbbrev} + + + ) + })} + + )} + + {/* ── Year scale header: top row = half-year, bottom row = quarter abbrev ── */} + {scale === 'year' && ( + <> + + {/* Half-year group labels */} + {halfYearGroups.map((hg, hi) => { + const groupX = hg.startWeek * colW + const groupW = (hg.endWeek - hg.startWeek) * colW + return ( + + + + {hg.label} + + + ) + })} + {/* Quarter bands in bottom row */} + {quarterGroups.map((qg, qi) => { + const groupX = qg.startWeek * colW + const groupW = (qg.endWeek - qg.startWeek) * colW + // Extract just the quarter number for the abbreviated label + const qLabel = qg.label.startsWith('Q') ? qg.label.split(' ')[0] : qg.label + return ( + + + + {qLabel} + + + ) + })} + + )} + {/* Header bottom border */} - {/* Row bars */} @@ -297,6 +579,7 @@ export default function GanttChart({ y={y} weekOffset={weekOffset} totalWeeks={totalWeeks} + colW={colW} dragging={dragging} svgColors={svgColors} weeklyDemand={weeklyDemand} diff --git a/client/src/components/timeline/GanttDependencyArrows.tsx b/client/src/components/timeline/GanttDependencyArrows.tsx index 26c1d85..1ea8a16 100644 --- a/client/src/components/timeline/GanttDependencyArrows.tsx +++ b/client/src/components/timeline/GanttDependencyArrows.tsx @@ -6,7 +6,7 @@ import type { EpicDependency, GanttDraggingState, } from '../../hooks/useGanttLayout' -import { COL_W, FEAT_ROW_H, STORY_ROW_H, EPIC_ROW_H, DEP_ARROW_COLOR } from '../../hooks/useGanttLayout' +import { FEAT_ROW_H, STORY_ROW_H, EPIC_ROW_H, DEP_ARROW_COLOR } from '../../hooks/useGanttLayout' // --------------------------------------------------------------------------- // Helpers @@ -29,6 +29,7 @@ interface GanttDependencyArrowsProps { epicById: Map rowY: Map weekOffset: number + colW: number dragging: GanttDraggingState | null } @@ -44,6 +45,7 @@ export default function GanttDependencyArrows({ epicById, rowY, weekOffset, + colW, dragging, }: GanttDependencyArrowsProps) { return ( @@ -69,9 +71,9 @@ export default function GanttDependencyArrows({ const predStart = predDragging ? dragging!.currentStart : predEntry.startWeek const succStart = succDragging ? dragging!.currentStart : succEntry.startWeek - const x1 = (predStart + weekOffset + predEntry.durationWeeks) * COL_W + const x1 = (predStart + weekOffset + predEntry.durationWeeks) * colW const y1 = predY + FEAT_ROW_H / 2 - const x2 = (succStart + weekOffset) * COL_W + const x2 = (succStart + weekOffset) * colW const y2 = succY + FEAT_ROW_H / 2 return ( @@ -102,9 +104,9 @@ export default function GanttDependencyArrows({ const predStart = predDragging ? dragging!.currentStart : predEntry.startWeek const succStart = succDragging ? dragging!.currentStart : succEntry.startWeek - const x1 = (predStart + weekOffset + predEntry.durationWeeks) * COL_W + const x1 = (predStart + weekOffset + predEntry.durationWeeks) * colW const y1 = predY + STORY_ROW_H / 2 - const x2 = (succStart + weekOffset) * COL_W + const x2 = (succStart + weekOffset) * colW const y2 = succY + STORY_ROW_H / 2 return ( @@ -130,9 +132,9 @@ export default function GanttDependencyArrows({ const succY = rowY.get(`epic-${dep.epicId}`) if (predY === undefined || succY === undefined) return null - const x1 = (predEpic.startWeek + weekOffset + predEpic.durationWeeks) * COL_W + const x1 = (predEpic.startWeek + weekOffset + predEpic.durationWeeks) * colW const y1 = predY + EPIC_ROW_H / 2 - const x2 = (succEpic.startWeek + weekOffset) * COL_W + const x2 = (succEpic.startWeek + weekOffset) * colW const y2 = succY + EPIC_ROW_H / 2 return ( diff --git a/client/src/components/timeline/GanttLabelPanel.tsx b/client/src/components/timeline/GanttLabelPanel.tsx index b1c0334..910132e 100644 --- a/client/src/components/timeline/GanttLabelPanel.tsx +++ b/client/src/components/timeline/GanttLabelPanel.tsx @@ -44,9 +44,21 @@ export default function GanttLabelPanel({ }: GanttLabelPanelProps) { // track which epic row has its dep-picker dropdown open const [depPickerEpicId, setDepPickerEpicId] = useState(null) + const [depPickerSearch, setDepPickerSearch] = useState('') + + function openDepPicker(epicId: string) { + setDepPickerEpicId(prev => prev === epicId ? null : epicId) + setDepPickerSearch('') + } // derive the full list of epic rows for the dep dropdown const allEpicRows = rows.filter((r): r is Extract => r.type === 'epic') + + // Expand / Collapse All toggle state + const allEpicIds = allEpicRows.map(r => r.epicId) + const allExpanded = allEpicIds.length > 0 && allEpicIds.every(id => expandedEpics.has(id)) + const allCollapsed = allEpicIds.length > 0 && allEpicIds.every(id => !expandedEpics.has(id)) + return (
Feature + + {/* Expand All / Collapse All pill toggle */} + {allEpicIds.length > 0 && ( +
+ + +
+ )}
{/* Label rows */} @@ -70,7 +102,7 @@ export default function GanttLabelPanel({
setExpandedEpics(prev => { const next = new Set(prev) @@ -80,134 +112,158 @@ export default function GanttLabelPanel({ }) } > - {/* Epic reorder arrows */} - {onMoveEpic && ( -
e.stopPropagation()} + {/* Line 1: reorder + toggle + title */} +
+ {onMoveEpic && ( +
e.stopPropagation()} + > + + +
+ )} + {isOpen ? '▼' : '▶'} + - - -
- )} - {isOpen ? '▼' : '▶'} - +
+ + {/* Line 2: feature mode + schedule mode + dep chips */} +
e.stopPropagation()} > - {row.epicName} - - {/* Feature mode button */} - {onUpdateEpicMode && ( - - )} - {/* Schedule mode button */} - {onUpdateEpicScheduleMode && ( - - )} - {/* Epic dependency chips + picker */} - {onAddEpicDep && ( -
e.stopPropagation()} - > - {/* existing dep chips */} - {epicDependencies - .filter(d => d.epicId === row.epicId) - .map(d => { - const depName = allEpicRows.find(r => r.epicId === d.dependsOnId)?.epicName ?? d.dependsOnId - return ( - - →{depName.slice(0, 6)} - - + {/* Feature mode button */} + {onUpdateEpicMode && ( + + )} + {/* Schedule mode button */} + {onUpdateEpicScheduleMode && ( - {/* dropdown picker */} - {depPickerEpicId === row.epicId && ( -
- {allEpicRows - .filter(r => r.epicId !== row.epicId && !epicDependencies.some(d => d.epicId === row.epicId && d.dependsOnId === r.epicId)) - .map(r => ( - + )} + {/* Epic dependency chips + picker */} + {onAddEpicDep && ( +
+ {epicDependencies + .filter(d => d.epicId === row.epicId) + .map(d => { + const depName = allEpicRows.find(r => r.epicId === d.dependsOnId)?.epicName ?? d.dependsOnId + return ( + - {r.epicName} - - ))} - {allEpicRows.filter(r => r.epicId !== row.epicId && !epicDependencies.some(d => d.epicId === row.epicId && d.dependsOnId === r.epicId)).length === 0 && ( - No epics available - )} -
- )} -
- )} + →{depName.slice(0, 8)} + + + ) + })} + + {depPickerEpicId === row.epicId && ( +
= row.epicCount - 4 ? 'bottom-full mb-1' : 'top-full mt-1'} left-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded shadow-lg py-1 min-w-[200px] max-h-72 flex flex-col`}> +
+ setDepPickerSearch(e.target.value)} + onClick={e => e.stopPropagation()} + className="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-400" + /> +
+
+ {allEpicRows + .filter(r => + r.epicId !== row.epicId && + !epicDependencies.some(d => d.epicId === row.epicId && d.dependsOnId === r.epicId) && + r.epicName.toLowerCase().includes(depPickerSearch.toLowerCase()) + ) + .map(r => ( + + ))} + {allEpicRows.filter(r => + r.epicId !== row.epicId && + !epicDependencies.some(d => d.epicId === row.epicId && d.dependsOnId === r.epicId) && + r.epicName.toLowerCase().includes(depPickerSearch.toLowerCase()) + ).length === 0 && ( + No epics found + )} +
+
+ )} +
+ )} +
) } diff --git a/client/src/components/timeline/ResourceHistogram.tsx b/client/src/components/timeline/ResourceHistogram.tsx index ad8ebaa..994bfed 100644 --- a/client/src/components/timeline/ResourceHistogram.tsx +++ b/client/src/components/timeline/ResourceHistogram.tsx @@ -258,8 +258,9 @@ export default function ResourceHistogram({ const cap = capacityLookup.get(`${w}|${rt.name}`) ?? 0 const barH = Math.min(demand * scale, BAR_MAX_H + 6) - const barX = (w + weekOffset) * colW + 4 - const barW = colW - 8 + const inset = Math.min(4, Math.max(1, Math.floor(colW / 4))) + const barX = (w + weekOffset) * colW + inset + const barW = Math.max(1, colW - inset * 2) const barY = rowY + ROW_H - 6 - barH const fill = barColour(demand, cap) diff --git a/client/src/hooks/useGanttLayout.ts b/client/src/hooks/useGanttLayout.ts index fedea5f..95f7c55 100644 --- a/client/src/hooks/useGanttLayout.ts +++ b/client/src/hooks/useGanttLayout.ts @@ -66,15 +66,30 @@ export interface GanttDraggingState { currentStart: number } +// --------------------------------------------------------------------------- +// Scale type +// --------------------------------------------------------------------------- +export type GanttScale = 'week' | 'month' | 'quarter' | 'year' + +/** Returns the pixel width per week for the given scale. */ +export function colWForScale(scale: GanttScale): number { + switch (scale) { + case 'month': return 28 + case 'quarter': return 16 + case 'year': return 8 + default: return 64 + } +} + // --------------------------------------------------------------------------- // Layout constants (shared across Gantt sub-components) // --------------------------------------------------------------------------- export const COL_W = 64 -export const EPIC_ROW_H = 36 +export const EPIC_ROW_H = 52 export const FEAT_ROW_H = 36 export const STORY_ROW_H = 28 export const HEADER_H = 44 -export const LABEL_W = 300 +export const LABEL_W = 380 export const DEP_ARROW_COLOR = '#9ca3af' // --------------------------------------------------------------------------- diff --git a/client/src/pages/BacklogPage.tsx b/client/src/pages/BacklogPage.tsx index 015433b..def67e6 100644 --- a/client/src/pages/BacklogPage.tsx +++ b/client/src/pages/BacklogPage.tsx @@ -71,6 +71,8 @@ export default function BacklogPage() { qc.invalidateQueries({ queryKey: ['backlog', projectId] }) qc.invalidateQueries({ queryKey: ['timeline', projectId] }) qc.invalidateQueries({ queryKey: ['resource-profile', projectId] }) + qc.invalidateQueries({ queryKey: ['epicDeps', projectId] }) + qc.invalidateQueries({ queryKey: ['feature-deps', projectId] }) } // Backlog-only invalidation: for metadata-only mutations (name, description, assumptions) diff --git a/client/src/pages/TimelinePage.tsx b/client/src/pages/TimelinePage.tsx index c5af10d..86d37e4 100644 --- a/client/src/pages/TimelinePage.tsx +++ b/client/src/pages/TimelinePage.tsx @@ -10,6 +10,8 @@ import GanttChart from '../components/timeline/GanttChart' import ResourceHistogram from '../components/timeline/ResourceHistogram' import TimelineTooltip from '../components/timeline/TimelineTooltip' import { getEpicColour } from '../lib/epicColours' +import type { GanttScale } from '../hooks/useGanttLayout' +import { colWForScale, LABEL_W } from '../hooks/useGanttLayout' const CATEGORY_HEADER_BG: Record = { ENGINEERING: 'bg-blue-100', @@ -157,9 +159,12 @@ function NamedResourcesPanel({ const rtDemand = demandByRt.get(rtName) if (isEffort && rtDemand) { - // T&M: render a demand-following mini histogram + // 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), 1) + const maxCap = Math.max(...Array.from(rtDemand.values()).map(d => d.capacity / personCount), 1) return (
{ const d = rtDemand.get(w) if (!d || d.demand <= 0) return null - const pct = Math.min(d.demand / maxCap, 1) + 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 ( @@ -189,7 +196,7 @@ function NamedResourcesPanel({ onMouseEnter={(e) => setTooltip({ x: e.clientX, y: e.clientY, - content: `${nr.name} · T&M\nWk ${w}: ${d.demand.toFixed(1)} / ${d.capacity.toFixed(1)} days (${Math.round(d.demand / d.capacity * 100)}%)`, + 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)} @@ -259,9 +266,16 @@ export default function TimelinePage() { const rlKey = `timeline.resourceLevel.${projectId}` const [resourceLevel, setResourceLevel] = useState(() => localStorage.getItem(rlKey) === 'true') + const SCALE_KEY = 'gantt-scale' + const [ganttScale, setGanttScale] = useState( + () => (localStorage.getItem(SCALE_KEY) as GanttScale | null) ?? 'week', + ) + const ganttColW = colWForScale(ganttScale) + // Scroll sync refs for Gantt + Histogram right panels const ganttScrollRef = useRef(null) const histScrollRef = useRef(null) + const topScrollRef = useRef(null) const isSyncingScroll = useRef(false) // Ref for PNG export — wraps the entire Gantt+histogram+named resources section @@ -270,18 +284,27 @@ export default function TimelinePage() { const handleGanttScroll = useCallback(() => { if (isSyncingScroll.current) return isSyncingScroll.current = true - if (histScrollRef.current && ganttScrollRef.current) { - histScrollRef.current.scrollLeft = ganttScrollRef.current.scrollLeft - } + const sl = ganttScrollRef.current?.scrollLeft ?? 0 + if (histScrollRef.current) histScrollRef.current.scrollLeft = sl + if (topScrollRef.current) topScrollRef.current.scrollLeft = sl isSyncingScroll.current = false }, []) const handleHistScroll = useCallback(() => { if (isSyncingScroll.current) return isSyncingScroll.current = true - if (ganttScrollRef.current && histScrollRef.current) { - ganttScrollRef.current.scrollLeft = histScrollRef.current.scrollLeft - } + const sl = histScrollRef.current?.scrollLeft ?? 0 + if (ganttScrollRef.current) ganttScrollRef.current.scrollLeft = sl + if (topScrollRef.current) topScrollRef.current.scrollLeft = sl + isSyncingScroll.current = false + }, []) + + const handleTopScroll = useCallback(() => { + if (isSyncingScroll.current) return + isSyncingScroll.current = true + const sl = topScrollRef.current?.scrollLeft ?? 0 + if (ganttScrollRef.current) ganttScrollRef.current.scrollLeft = sl + if (histScrollRef.current) histScrollRef.current.scrollLeft = sl isSyncingScroll.current = false }, []) @@ -300,10 +323,9 @@ export default function TimelinePage() { const container = ganttContainerRef.current if (!container) return - // Exact dimensions: all panels share labelW=300 and colW=64 - const EXPORT_LABEL_W = 300 - const EXPORT_COL_W = 64 - const fullWidth = EXPORT_LABEL_W + totalWeeks * EXPORT_COL_W + // Exact dimensions: label panel + chart columns at the current scale + const EXPORT_LABEL_W = LABEL_W + const fullWidth = EXPORT_LABEL_W + totalWeeks * ganttColW const fullHeight = container.scrollHeight // Dark-mode aware background colour — read from DOM since handler is outside hook scope @@ -359,6 +381,8 @@ export default function TimelinePage() { } } + const initialScheduleDone = useRef(false) + const { data: project } = useQuery({ queryKey: ['project', projectId], queryFn: () => api.get(`/projects/${projectId}`).then(r => r.data), @@ -391,6 +415,18 @@ export default function TimelinePage() { } }, [editingFeatureId, timeline]) + // Auto-schedule on page load ONLY if no entries exist yet (first run for new projects). + // For projects with existing entries, the user drives rescheduling via the button. + useEffect(() => { + if (!initialScheduleDone.current && timeline !== undefined && project !== undefined) { + initialScheduleDone.current = true + if (timeline.entries.length === 0) { + const body = project.startDate ? { startDate: project.startDate.slice(0, 10), resourceLevel } : { resourceLevel } + scheduleTimeline.mutate(body) + } + } + }, [timeline, project]) + const invalidate = () => qc.invalidateQueries({ queryKey: ['timeline', projectId] }) const scheduleTimeline = useMutation({ @@ -519,7 +555,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] }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['resource-types', projectId] }); setScheduleStale(true) }, }) const addNamedResource = useMutation({ @@ -530,6 +566,7 @@ export default function TimelinePage() { }).then(r => r.data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + setScheduleStale(true) }, }) @@ -538,6 +575,16 @@ export default function TimelinePage() { api.delete(`/projects/${projectId}/resource-types/${rtId}/named-resources/${nrId}`).then(r => r.data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['timeline', projectId] }) + setScheduleStale(true) + }, + }) + + const updateNamedResource = useMutation({ + 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] }) + setScheduleStale(true) }, }) @@ -683,7 +730,7 @@ export default function TimelinePage() { Timeline } > -
+

Timeline Planner

@@ -784,7 +831,7 @@ export default function TimelinePage() { {/* Stale schedule banner */} {scheduleStale && (
- ⚠ Dependencies or epic mode changed — re-run Auto-schedule to apply. + ⚠ Schedule may be stale (dependencies, epic mode, or resourcing changed) — re-run Auto-schedule to apply. @@ -845,14 +892,78 @@ export default function TimelinePage() { {nrs.length > 0 && (
{nrs.map((nr, i) => ( -
- {nr.name} - {nr.startWeek != null && W{nr.startWeek}–{nr.endWeek ?? '∞'}} - {nr.allocationPct}% +
+ {nr.name} + {/* Mode selector */} + {nr.id && ( + + )} + {/* % input — only shown for non-EFFORT modes */} + {nr.id && (nr.allocationMode ?? 'EFFORT') !== 'EFFORT' && ( +
+ { + const val = Math.min(100, Math.max(1, parseInt(e.target.value) || 100)) + updateNamedResource.mutate({ rtId: rt.id, nrId: nr.id!, allocationMode: nr.allocationMode ?? 'EFFORT', allocationPercent: val }) + }} + className="w-12 text-xs border border-gray-200 dark:border-gray-600 rounded px-1 py-0 text-right bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300" + /> + % +
+ )} + {/* Start/end week — only for TIMELINE mode */} + {nr.id && nr.allocationMode === 'TIMELINE' && ( +
+ W + { + const val = e.target.value.trim() === '' ? null : Math.max(1, parseInt(e.target.value) || 1) + updateNamedResource.mutate({ rtId: rt.id, nrId: nr.id!, allocationMode: 'TIMELINE', allocationPercent: nr.allocationPercent ?? 100, allocationStartWeek: val }) + }} + className="w-10 text-xs border border-gray-200 dark:border-gray-600 rounded px-1 py-0 text-right bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 placeholder-gray-300" + /> + + { + const val = e.target.value.trim() === '' ? null : Math.max(1, parseInt(e.target.value) || 1) + updateNamedResource.mutate({ rtId: rt.id, nrId: nr.id!, allocationMode: 'TIMELINE', allocationPercent: nr.allocationPercent ?? 100, allocationEndWeek: val }) + }} + className="w-10 text-xs border border-gray-200 dark:border-gray-600 rounded px-1 py-0 text-right bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 placeholder-gray-300" + /> +
+ )} + {/* remove button */} {nr.id && ( )} @@ -893,8 +1004,23 @@ export default function TimelinePage() { {/* Gantt chart */}
-
+

Gantt Chart

+ {/* Scale toggle */} +
+ {(['week', 'month', 'quarter', 'year'] as const).map(s => ( + + ))} +
{isLoading &&
Loading…
} @@ -907,6 +1033,15 @@ export default function TimelinePage() { {!isLoading && timeline?.entries && timeline.entries.length > 0 && (
+ {/* Top mirror scrollbar — synced with the Gantt scroll container */} +
+
+
{ const entry = timeline.entries.find(e => e.featureId === featureId) if (!entry) return @@ -962,8 +1098,8 @@ export default function TimelinePage() { weeklyDemand={timeline.weeklyDemand} weeklyCapacity={timeline.weeklyCapacity} totalWeeks={totalWeeks} - colW={64} - labelW={300} + colW={ganttColW} + labelW={LABEL_W} weekOffset={timeline.onboardingWeeks ?? 0} scrollContainerRef={histScrollRef} onScroll={handleHistScroll} @@ -975,8 +1111,8 @@ export default function TimelinePage() { diff --git a/client/src/types/backlog.ts b/client/src/types/backlog.ts index 4c7b567..612d756 100644 --- a/client/src/types/backlog.ts +++ b/client/src/types/backlog.ts @@ -180,6 +180,9 @@ export interface NamedResourceEntry { endWeek: number | null allocationPct: number allocationMode?: string + allocationPercent?: number + allocationStartWeek?: number | null + allocationEndWeek?: number | null } export interface TimelineSummary { diff --git a/docs/screenshots/backlog.png b/docs/screenshots/backlog.png index 2c698e3..3990374 100644 Binary files a/docs/screenshots/backlog.png and b/docs/screenshots/backlog.png differ diff --git a/docs/screenshots/effort-review.png b/docs/screenshots/effort-review.png index da8f21d..d9a5acb 100644 Binary files a/docs/screenshots/effort-review.png and b/docs/screenshots/effort-review.png differ diff --git a/docs/screenshots/projects.png b/docs/screenshots/projects.png index 7c0bf63..5e7c28e 100644 Binary files a/docs/screenshots/projects.png and b/docs/screenshots/projects.png differ diff --git a/docs/screenshots/resource-profile.png b/docs/screenshots/resource-profile.png index daf246a..64b25d1 100644 Binary files a/docs/screenshots/resource-profile.png and b/docs/screenshots/resource-profile.png differ diff --git a/docs/screenshots/templates.png b/docs/screenshots/templates.png index 9771a79..e70362a 100644 Binary files a/docs/screenshots/templates.png and b/docs/screenshots/templates.png differ diff --git a/docs/screenshots/timeline.png b/docs/screenshots/timeline.png index 50b2fa1..9cefa14 100644 Binary files a/docs/screenshots/timeline.png and b/docs/screenshots/timeline.png differ diff --git a/server/src/lib/scopeDocumentRenderer.ts b/server/src/lib/scopeDocumentRenderer.ts index a1c8f67..e273048 100644 --- a/server/src/lib/scopeDocumentRenderer.ts +++ b/server/src/lib/scopeDocumentRenderer.ts @@ -136,11 +136,6 @@ function renderGanttSvg(td: TimelineData): string { return d } - function monthLabel(d: Date): string { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - return `${months[d.getMonth()]} ${String(d.getFullYear()).slice(2)}` - } - // Build SVG parts const parts: string[] = [] @@ -156,33 +151,56 @@ function renderGanttSvg(td: TimelineData): string { // Header row background parts.push(``) - // Week/month labels in header + // Month-scale header: two-row layout (top = month label, bottom = week-within-month) + // Build month groups + interface MonthGroupSvg { label: string; startWeek: number; endWeek: number } + const monthGroupsSvg: MonthGroupSvg[] = [] + const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + if (td.startDate) { - let lastMonth = -1 - for (let w = 0; w < totalWeeks; w++) { + let currentMonthKey = -1 + let groupStart = 0 + for (let w = 0; w <= totalWeeks; w++) { const d = weekToDate(w) - if (!d) break - const month = d.getMonth() - if (month !== lastMonth) { - lastMonth = month - const x = LABEL_W + w * COL_W + 3 - parts.push(`${monthLabel(d)}`) - // tick mark at month boundary - const lineX = LABEL_W + w * COL_W - parts.push(``) + const monthKey = d ? d.getFullYear() * 100 + d.getMonth() : -999 + if (w === totalWeeks || monthKey !== currentMonthKey) { + if (w > 0 && currentMonthKey !== -1) { + const startD = weekToDate(groupStart)! + const label = `${MONTH_NAMES[startD.getMonth()]} ${String(startD.getFullYear()).slice(2)}` + monthGroupsSvg.push({ label, startWeek: groupStart, endWeek: w }) + } + currentMonthKey = monthKey + groupStart = w } } } else { - for (let w = 0; w < totalWeeks; w += 2) { - const x = LABEL_W + w * COL_W + 3 - parts.push(`Wk ${w + 1}`) + for (let w = 0; w < totalWeeks; w += 4) { + const monthNum = Math.floor(w / 4) + 1 + monthGroupsSvg.push({ label: `Month ${monthNum}`, startWeek: w, endWeek: Math.min(w + 4, totalWeeks) }) } } - // Week numbers row (bottom of header) - for (let w = 0; w < totalWeeks; w++) { - const x = LABEL_W + w * COL_W - parts.push(`${w + 1}`) + // Mid-header separator line + const MID_Y = Math.floor(HEADER_H / 2) + 2 + parts.push(``) + + // Render month groups: top-row label + bottom-row week numbers + for (const mg of monthGroupsSvg) { + const groupX = LABEL_W + mg.startWeek * COL_W + const groupW = (mg.endWeek - mg.startWeek) * COL_W + // Full-height group separator + parts.push(``) + // Month label centred in top row + const labelX = groupX + groupW / 2 + parts.push(`${esc(mg.label)}`) + // Week-within-month numbers in bottom row + for (let wi = 0; wi < mg.endWeek - mg.startWeek; wi++) { + const wx = LABEL_W + (mg.startWeek + wi) * COL_W + if (wi > 0) { + parts.push(``) + } + parts.push(`${wi + 1}`) + } } // Render epic groups and feature rows diff --git a/server/src/routes/csv.ts b/server/src/routes/csv.ts index cceaa94..c0f7ad2 100644 --- a/server/src/routes/csv.ts +++ b/server/src/routes/csv.ts @@ -13,6 +13,7 @@ router.use(authenticate) const CSV_HEADERS = [ 'Type', 'Epic', 'Feature', 'Story', 'Task', 'Template', + 'TemplateSize', 'ResourceType', 'HoursEffort', 'DurationDays', 'Description', 'Assumptions', @@ -35,6 +36,7 @@ interface CsvRow { EpicDependsOn: string FeatureDependsOn: string Template: string + TemplateSize: string ResourceType: string // legacy fields — kept for backwards compat (old CSVs may still have these) HoursExtraSmall: string @@ -63,6 +65,7 @@ export interface StagedRow { epicDependsOn: string[] // array of epic names featureDependsOn: string[] // array of feature names template: string + templateSize: string // 'XS' | 'Small' | 'Medium' | 'Large' | 'XL' | '' resourceType: string // legacy fields kept for backwards compat hoursExtraSmall: number @@ -92,6 +95,21 @@ function parseStatus(val: string | undefined): boolean { return (val?.trim().toLowerCase() === 'inactive') ? false : true } +/** Pick hours from a TemplateTask based on the sizing tier string */ +function pickHours( + task: { hoursExtraSmall: number; hoursSmall: number; hoursMedium: number; hoursLarge: number; hoursExtraLarge: number }, + size: string, +): number { + switch (size.toLowerCase()) { + case 'xs': return task.hoursExtraSmall ?? 0 + case 'small': return task.hoursSmall ?? 0 + case 'medium': return task.hoursMedium ?? 0 + case 'large': return task.hoursLarge ?? 0 + case 'xl': return task.hoursExtraLarge ?? 0 + default: return 0 + } +} + /** Prevent CSV formula injection by prefixing dangerous characters with a single quote */ export function sanitizeCsvCell(value: string): string { if (/^[=+\-@\t\r]/.test(value)) { @@ -140,24 +158,60 @@ router.get('/export-csv', asyncHandler(async (req: AuthRequest, res: Response) = const featureDepsRaw = await prisma.featureDependency.findMany({ where: { feature: { epic: { projectId: req.params.projectId as string } } }, - include: { dependsOn: { select: { name: true } } }, + include: { + feature: { select: { epicId: true } }, + dependsOn: { select: { name: true, epicId: true, epic: { select: { name: true } } } }, + }, }) const featureDepNames = new Map() for (const d of featureDepsRaw) { const arr = featureDepNames.get(d.featureId) ?? [] - arr.push(d.dependsOn.name) + // Use qualified "EpicName: FeatureName" format for cross-epic deps so they round-trip correctly + const isCrossEpic = d.feature.epicId !== d.dependsOn.epicId + arr.push(isCrossEpic ? `${d.dependsOn.epic.name}: ${d.dependsOn.name}` : d.dependsOn.name) featureDepNames.set(d.featureId, arr) } if (epics.length === 0) { - // blank template with one example row - rows.push(['Task', 'My Epic', 'My Feature', 'My Story', 'My Task', '', 'Developer', '', '', '', '', '', '', '', '', '', '', '']) + // Description row — explains each column + rows.push([ + 'Type: Epic | Feature | Story | Task', + 'Epic name (required on all rows)', + 'Feature name (required on Feature/Story/Task rows)', + 'Story name (required on Story/Task rows)', + 'Task name (required on Task rows)', + 'Template name to link to story (Story rows only)', + 'Template size tier: XS | Small | Medium | Large | XL (Story rows only — auto-expands tasks)', + 'Resource type name (Task rows only)', + 'Hours of effort (Task rows only)', + 'Duration in days — auto-calculated if blank (Task rows only)', + 'Description (rich text supported)', + 'Assumptions (rich text supported)', + 'Epic status: active | inactive', + 'Feature status: active | inactive', + 'Story status: active | inactive', + 'Epic feature mode: sequential | parallel', + 'Feature story mode: sequential | parallel', + 'Epic dependencies: comma-separated epic names this epic depends on', + 'Feature dependencies: comma-separated feature names this feature depends on (same-epic: "FeatureName"; cross-epic: "EpicName: FeatureName")', + ]) + // Example Epic row + rows.push(['Epic', 'Platform Setup', '', '', '', '', '', '', '', '', 'Core infrastructure and environment setup', '', 'active', '', '', 'sequential', '', '', '']) + rows.push(['Epic', 'Mobile App', '', '', '', '', '', '', '', '', 'Mobile front-end layer', '', 'active', '', '', 'sequential', '', 'Platform Setup', '']) + // Example Feature rows — same-epic dep, then cross-epic dep + rows.push(['Feature', 'Platform Setup', 'Authentication', '', '', '', '', '', '', '', 'Login and registration flows', '', '', 'active', '', '', 'sequential', '', '']) + rows.push(['Feature', 'Platform Setup', 'User Profile', '', '', '', '', '', '', '', 'User profile management', '', '', 'active', '', '', 'sequential', '', 'Authentication']) + rows.push(['Feature', 'Mobile App', 'Login Screen', '', '', '', '', '', '', '', 'Mobile login UI', '', '', 'active', '', '', 'sequential', '', 'Platform Setup: Authentication']) + // Example Story row (with template) + rows.push(['Story', 'Platform Setup', 'Authentication', 'User can log in', '', 'Login Flow', 'Medium', '', '', '', 'As a user I can log in with email and password', '', '', '', 'active', '', '', '', '']) + // Example Task row + rows.push(['Task', 'Platform Setup', 'Authentication', 'User can log in', 'Backend API', '', '', 'Developer', '8', '', '', '', '', '', '', '', '', '', '']) } else { for (const epic of epics) { // Epic row rows.push([ 'Epic', sanitizeCsvCell(epic.name), '', '', '', - '', '', '', '', + '', '', '', '', '', sanitizeCsvCell(epic.description ?? ''), sanitizeCsvCell(epic.assumptions ?? ''), epic.isActive ? 'active' : 'inactive', '', '', epic.featureMode ?? 'sequential', '', @@ -168,7 +222,7 @@ router.get('/export-csv', asyncHandler(async (req: AuthRequest, res: Response) = // Feature row rows.push([ 'Feature', sanitizeCsvCell(epic.name), sanitizeCsvCell(feature.name), '', '', - '', '', '', '', + '', '', '', '', '', sanitizeCsvCell(feature.description ?? ''), sanitizeCsvCell(feature.assumptions ?? ''), '', feature.isActive ? 'active' : 'inactive', '', '', feature.featureMode ?? 'sequential', @@ -180,6 +234,7 @@ router.get('/export-csv', asyncHandler(async (req: AuthRequest, res: Response) = rows.push([ 'Story', sanitizeCsvCell(epic.name), sanitizeCsvCell(feature.name), sanitizeCsvCell(story.name), '', sanitizeCsvCell(story.appliedTemplate?.name ?? ''), + '', // TemplateSize — UserStory has no sizingTier field; exported blank '', '', '', sanitizeCsvCell(story.description ?? ''), sanitizeCsvCell(story.assumptions ?? ''), '', '', story.isActive ? 'active' : 'inactive', @@ -191,7 +246,8 @@ router.get('/export-csv', asyncHandler(async (req: AuthRequest, res: Response) = for (const task of story.tasks) { rows.push([ 'Task', sanitizeCsvCell(epic.name), sanitizeCsvCell(feature.name), sanitizeCsvCell(story.name), sanitizeCsvCell(task.name), - '', + '', // Template + '', // TemplateSize sanitizeCsvCell(task.resourceType?.name ?? ''), String(task.hoursEffort), String(task.durationDays != null ? Math.round(task.durationDays * 100) / 100 : ''), @@ -271,6 +327,7 @@ router.post('/stage-csv', asyncHandler(async (req: AuthRequest, res: Response) = const rawFeatureMode = raw.FeatureMode?.trim().toLowerCase() || 'sequential' const featureMode = (rawFeatureMode === 'sequential' || rawFeatureMode === 'parallel') ? rawFeatureMode : 'sequential' const template = raw.Template?.trim() ?? '' + const templateSize = raw.TemplateSize?.trim() ?? '' const epicDependsOn = (raw.EpicDependsOn ?? '') .split(',') @@ -305,6 +362,15 @@ router.post('/stage-csv', asyncHandler(async (req: AuthRequest, res: Response) = warnings.push(`Template column is only applied on Story rows — will be ignored for this ${type} row`) } + // TemplateSize validation — only meaningful on Story rows + const validSizes = new Set(['xs', 'small', 'medium', 'large', 'xl']) + if (type === 'Story' && templateSize && !validSizes.has(templateSize.toLowerCase())) { + warnings.push(`TemplateSize "${templateSize}" is not valid — use XS, Small, Medium, Large, or XL`) + } + if (type !== 'Story' && templateSize) { + warnings.push(`TemplateSize column is only applied on Story rows — will be ignored for this ${type} row`) + } + // Status-on-wrong-type warnings if (type !== 'Epic' && raw.EpicStatus?.trim()) { warnings.push(`EpicStatus is only applied on Epic rows — will be ignored for this ${type} row`) @@ -335,6 +401,7 @@ router.post('/stage-csv', asyncHandler(async (req: AuthRequest, res: Response) = epicDependsOn, featureDependsOn, template, + templateSize, resourceType, hoursExtraSmall: parseNum(raw.HoursExtraSmall), hoursSmall: parseNum(raw.HoursSmall), @@ -353,6 +420,8 @@ router.post('/stage-csv', asyncHandler(async (req: AuthRequest, res: Response) = // Cross-row dependency name validation (warnings only — import still proceeds) const knownEpicNames = new Set(staged.filter(r => r.type === 'Epic').map(r => r.epic)) const knownFeatureNames = new Set(staged.filter(r => r.type === 'Feature').map(r => r.feature)) + // Build "EpicName||FeatureName" set for cross-epic qualified lookup + const knownEpicFeaturePairs = new Set(staged.filter(r => r.type === 'Feature').map(r => `${r.epic}||${r.feature}`)) for (const row of staged) { for (const depName of row.epicDependsOn) { if (!knownEpicNames.has(depName)) { @@ -360,7 +429,15 @@ router.post('/stage-csv', asyncHandler(async (req: AuthRequest, res: Response) = } } for (const depName of row.featureDependsOn) { - if (!knownFeatureNames.has(depName)) { + const colonIdx = depName.indexOf(': ') + if (colonIdx !== -1) { + // Cross-epic qualified format: "EpicName: FeatureName" + const epicPart = depName.slice(0, colonIdx) + const featPart = depName.slice(colonIdx + 2) + if (!knownEpicFeaturePairs.has(`${epicPart}||${featPart}`)) { + row.warnings.push(`FeatureDependsOn: '${depName}' not found in this import`) + } + } else if (!knownFeatureNames.has(depName)) { row.warnings.push(`FeatureDependsOn: '${depName}' not found in this import`) } } @@ -466,6 +543,15 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) const epicMap = new Map() // epic name → epic id const featureMap = new Map() // "epic||feature" → feature id const storyMap = new Map() // "epic||feature||story" → story id + const storyTemplateMap = new Map() + + // Build set of story keys that have at least one Task row in the CSV (used for Gap 1 auto-expand check) + const storyKeysWithTaskRows = new Set() + for (const row of rows) { + if (row.type === 'Task' && row.story) { + storyKeysWithTaskRows.add(`${row.epic}||${row.feature}||${row.story}`) + } + } let epicOrder = await tx.epic.count({ where: { projectId } }) @@ -564,6 +650,7 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) ? await tx.featureTemplate.findUnique({ where: { name: row.template } }) : null const appliedTemplateId = templateRecord?.id ?? null + const templateSize = isStoryRow ? (row.templateSize ?? '') : '' let story = await tx.userStory.findFirst({ where: { featureId, name: row.story } }) if (!story) { @@ -596,6 +683,44 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) storiesUpdated++ } storyMap.set(storyKey, story.id) + + // Track template info for Gap 2 (Task rows that need hour backfill) + storyTemplateMap.set(storyKey, { appliedTemplateId, templateSize }) + + // Gap 1: Auto-expand template tasks when no Task rows for this story appear in the CSV + if (appliedTemplateId && templateSize && !storyKeysWithTaskRows.has(storyKey)) { + const tmpl = await tx.featureTemplate.findUnique({ + where: { id: appliedTemplateId }, + include: { tasks: { orderBy: { order: 'asc' } } }, + }) + if (tmpl) { + let taskOrder = await tx.task.count({ where: { userStoryId: story.id } }) + for (const tmplTask of tmpl.tasks) { + const hours = pickHours(tmplTask, templateSize) + const tmplRt = tmplTask.resourceTypeName + ? rtByName.get(tmplTask.resourceTypeName.toLowerCase()) + : undefined + const tmplResourceTypeId = tmplRt?.id ?? null + const tmplHoursPerDay = tmplRt?.hoursPerDay ?? fallbackHoursPerDay + const existingTask = await tx.task.findFirst({ + where: { userStoryId: story.id, name: tmplTask.name }, + }) + if (!existingTask) { + await tx.task.create({ + data: { + name: tmplTask.name, + userStoryId: story.id, + order: taskOrder++, + resourceTypeId: tmplResourceTypeId, + hoursEffort: hours, + durationDays: calcDurationDays(hours, tmplHoursPerDay), + }, + }) + tasksCreated++ + } + } + } + } } const storyId = storyMap.get(storyKey)! @@ -611,6 +736,20 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) const resourceTypeId = resourceType?.id ?? null const hoursPerDay = resourceType?.hoursPerDay ?? fallbackHoursPerDay + // Gap 2: Backfill hours from template when row hours are blank (0) and story has Template+TemplateSize + let effectiveHoursEffort = row.hoursEffort + if (effectiveHoursEffort === 0) { + const storyTmpl = storyTemplateMap.get(storyKey) + if (storyTmpl?.appliedTemplateId && storyTmpl.templateSize) { + const tmplTask = await tx.templateTask.findFirst({ + where: { templateId: storyTmpl.appliedTemplateId, name: row.task }, + }) + if (tmplTask) { + effectiveHoursEffort = pickHours(tmplTask, storyTmpl.templateSize) + } + } + } + const task = await tx.task.findFirst({ where: { userStoryId: storyId, name: row.task } }) if (!task) { const taskCount = await tx.task.count({ where: { userStoryId: storyId } }) @@ -620,8 +759,8 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) userStoryId: storyId, order: taskCount, resourceTypeId, - hoursEffort: row.hoursEffort, - durationDays: row.durationDays || calcDurationDays(row.hoursEffort, hoursPerDay), + hoursEffort: effectiveHoursEffort, + durationDays: row.durationDays || calcDurationDays(effectiveHoursEffort, hoursPerDay), description: row.description || null, assumptions: row.assumptions || null, }, @@ -632,8 +771,8 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) where: { id: task.id }, data: { resourceTypeId, - hoursEffort: row.hoursEffort, - durationDays: row.durationDays || calcDurationDays(row.hoursEffort, hoursPerDay), + hoursEffort: effectiveHoursEffort, + durationDays: row.durationDays || calcDurationDays(effectiveHoursEffort, hoursPerDay), description: row.description || null, assumptions: row.assumptions || null, }, @@ -664,19 +803,31 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) } // ── Feature dependencies ────────────────────────────────────────────────── - // Build name→id map from what was just created/updated - const featureNameToId = new Map() + // Build epic-scoped name→id map: epicName → (featureName → featureId) + // Scoping to the same epic prevents cross-epic name collisions when templates + // share identical feature names (e.g. every epic has "Infrastructure & Environment") + const featureNameByEpic = new Map>() for (const [featureKey, featureId] of featureMap.entries()) { - // featureKey is "epicName||featureName" — extract just the feature name - const featureName = featureKey.split('||')[1] - if (featureName) featureNameToId.set(featureName, featureId) + const [epicName, featureName] = featureKey.split('||') + if (!featureNameByEpic.has(epicName)) featureNameByEpic.set(epicName, new Map()) + featureNameByEpic.get(epicName)!.set(featureName, featureId) } for (const row of rows) { if (row.type !== 'Feature' || !row.featureDependsOn?.length) continue const featureId = featureMap.get(`${row.epic}||${row.feature}`) if (!featureId) continue + const epicFeatures = featureNameByEpic.get(row.epic) ?? new Map() for (const depName of row.featureDependsOn) { - const depFeatureId = featureNameToId.get(depName) + // Support "EpicName: FeatureName" for cross-epic deps; plain name = same-epic + let depFeatureId: string | undefined + const colonIdx = depName.indexOf(': ') + if (colonIdx !== -1) { + const epicPart = depName.slice(0, colonIdx) + const featPart = depName.slice(colonIdx + 2) + depFeatureId = featureNameByEpic.get(epicPart)?.get(featPart) + } else { + depFeatureId = epicFeatures.get(depName) + } if (!depFeatureId || depFeatureId === featureId) continue await tx.featureDependency.upsert({ where: { featureId_dependsOnId: { featureId, dependsOnId: depFeatureId } }, diff --git a/server/src/routes/timeline.ts b/server/src/routes/timeline.ts index 3cdf21d..9099a8b 100644 --- a/server/src/routes/timeline.ts +++ b/server/src/routes/timeline.ts @@ -10,12 +10,12 @@ async function ownedProject(projectId: string, userId: string) { return prisma.project.findFirst({ where: { id: projectId, ownerId: userId } }) } -function computeDates(projectStartDate: Date | null, startWeek: number, durationWeeks: number) { +function computeDates(projectStartDate: Date | null, startWeek: number, durationWeeks: number, onboardingWeeks = 0) { if (!projectStartDate) return { startDate: null, endDate: null } const start = new Date(projectStartDate) - start.setDate(start.getDate() + startWeek * 7) + start.setDate(start.getDate() + (startWeek + onboardingWeeks) * 7) const end = new Date(projectStartDate) - end.setDate(end.getDate() + (startWeek + durationWeeks) * 7) + end.setDate(end.getDate() + (startWeek + durationWeeks + onboardingWeeks) * 7) return { startDate: start.toISOString(), endDate: end.toISOString() } } @@ -246,6 +246,9 @@ function buildResponse( endWeek: isFullProject ? null : (nr.allocationEndWeek ?? (derivedRt?.end ?? 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, } }) } @@ -305,7 +308,7 @@ function buildResponse( isManual: e.isManual, resourceBreakdown: breakdown, effectiveEngineers, - ...computeDates(project.startDate, e.startWeek, e.durationWeeks), + ...computeDates(project.startDate, e.startWeek, e.durationWeeks, project.onboardingWeeks ?? 0), } }), } @@ -752,6 +755,37 @@ router.post('/schedule', asyncHandler(async (req: AuthRequest, res: Response) => } } + // Fallback: if any features weren't processed (cycle or unresolvable deps), + // schedule them at the end of their epic's predecessor chain so nothing is silently dropped + if (processed.length < allFeatures.length) { + // Compute max finishWeek for each epic among features that *were* processed + const epicMaxFinish = new Map() + for (const f of allFeatures) { + const fw = finishWeeks.get(f.id) + if (fw === undefined) continue + const prev = epicMaxFinish.get(f.epic.id) ?? 0 + if (fw > prev) epicMaxFinish.set(f.epic.id, fw) + } + // For each unscheduled feature: start at max(epic anchor, all prev-epic finishes) + for (const f of allFeatures) { + if (startWeeks.has(f.id)) continue + let earliest = f.epic.timelineStartWeek ?? 0 + // Walk the sorted epic chain up to this epic and find the latest finish + for (const prevEpic of sortedEpics) { + if (prevEpic.order >= f.epic.order) break + const prevFinish = epicMaxFinish.get(prevEpic.id) ?? 0 + if (prevFinish > earliest) earliest = prevFinish + } + startWeeks.set(f.id, earliest) + finishWeeks.set(f.id, earliest + featureDurationWeeks(f)) + processed.push(f.id) + // Update epicMaxFinish so subsequent features in the same epic can build on this + const cur = epicMaxFinish.get(f.epic.id) ?? 0 + const newFinish = earliest + featureDurationWeeks(f) + if (newFinish > cur) epicMaxFinish.set(f.epic.id, newFinish) + } + } + // Tracks actual per-week resource consumption from the levelling simulation // key: `${rtName}|${week}` → days consumed; populated only when resourceLevel=true const weeklyConsumptionMap = new Map() @@ -1202,8 +1236,9 @@ router.get('/export/csv', asyncHandler(async (req: AuthRequest, res: Response) = for (const e of timelineEntries) { const featureName = e.feature.name.replace(/,/g, ' ') const epicName = e.feature.epic.name.replace(/,/g, ' ') - const startDate = toDateStr(project.startDate, e.startWeek) - const endDate = toDateStr(project.startDate, e.startWeek + e.durationWeeks) + const onboardingWeeks = project.onboardingWeeks ?? 0 + const startDate = toDateStr(project.startDate, e.startWeek + onboardingWeeks) + const endDate = toDateStr(project.startDate, e.startWeek + e.durationWeeks + onboardingWeeks) ganttRows.push(`${featureName},${epicName},${e.startWeek},${e.durationWeeks},${startDate},${endDate}`) } @@ -1348,7 +1383,7 @@ router.put('/:featureId', asyncHandler(async (req: AuthRequest, res: Response) = startWeek: entry.startWeek, durationWeeks: entry.durationWeeks, isManual: entry.isManual, - ...computeDates(project.startDate, entry.startWeek, entry.durationWeeks), + ...computeDates(project.startDate, entry.startWeek, entry.durationWeeks, project.onboardingWeeks ?? 0), }) }))