From 21c9144733789b64b9c48331880788b55b515e4b Mon Sep 17 00:00:00 2001 From: Guus Ekkelenkamp Date: Tue, 2 Jun 2026 13:07:53 +0200 Subject: [PATCH 1/2] fix(ui): don't crash the timeline on blocks without a startTime The leftover-sidebar change replaced the timeline's `startTime != null` guard with `!unplaced`, so legacy/persisted blocks that have no startTime (previously hidden) flowed into DayTimeline and crashed timeToMinutes ("undefined is not an object (evaluating 'time.split')") on startup. Restore the guard: the timeline shows only placed blocks that actually have a startTime; unplaced blocks still route to the sidebar. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/pages/WeekPage.test.tsx | 4 +++- src/ui/pages/WeekPage.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/pages/WeekPage.test.tsx b/src/ui/pages/WeekPage.test.tsx index 8834b41..e872dc5 100644 --- a/src/ui/pages/WeekPage.test.tsx +++ b/src/ui/pages/WeekPage.test.tsx @@ -327,10 +327,12 @@ describe('WeekPage render states', () => { { startTime: '09:00', origin: 'llm', urlPattern: 'u', commits: [{ a: 1 }], linearIssues: [{ b: 2 }] }, // an unplaced leftover is routed to the sidebar, not the timeline { startTime: '00:00', origin: 'llm-pattern', urlPattern: 'u2', unplaced: true, leftoverReason: 'suggestion' }, + // a legacy block with no startTime must NOT reach the timeline (would crash timeToMinutes) + { startTime: null, origin: 'manual', urlPattern: 'u3' }, ] renderPage() expect(screen.getByTestId('daytimeline')).toBeInTheDocument() - // only the placed (non-unplaced) block counts as a concept on the timeline + // only the placed block with a real startTime counts as a concept on the timeline expect(screen.getByTestId('dt-concept-len')).toHaveTextContent('1') // commits/linear pulled from first block fallback expect(screen.getByTestId('dt-commits-len')).toHaveTextContent('1') diff --git a/src/ui/pages/WeekPage.tsx b/src/ui/pages/WeekPage.tsx index b100010..8f80d60 100644 --- a/src/ui/pages/WeekPage.tsx +++ b/src/ui/pages/WeekPage.tsx @@ -585,7 +585,7 @@ export function WeekPage() { date={week.selectedDate} entries={selectedEntries} suggestions={daySubmitted ? [] : suggestions} - conceptBlocks={historyStore.blocksForDate.filter(b => !b.unplaced)} + conceptBlocks={historyStore.blocksForDate.filter(b => !b.unplaced && b.startTime != null)} commits={dayCommits} linearIssues={dayLinearIssues} onBookSuggestion={handleBookSuggestion} From 5f776aa07ce945674fe66bfe3f24700042cd00ec Mon Sep 17 00:00:00 2001 From: Guus Ekkelenkamp Date: Tue, 2 Jun 2026 13:25:00 +0200 Subject: [PATCH 2/2] feat(ui): delete from timeline, extend grid past 18:00, drop "+ toevoegen" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DayTimeline: hover ✕ on concept blocks deletes them straight from the timeline (new onDeleteConcept prop, wired to removeBlock in WeekPage) — no booking modal. Booked entries keep their existing edit/delete-via-modal flow. - DayTimeline: dynamic day extent. The grid now grows to fit blocks that start before 08:00 or end after 18:00 (e.g. overflow work, late meetings) instead of clipping them off-screen — start/end hours, labels, rows, blockTop/blockPx and the drag handlers all derive from the computed extent (default 08:00–18:00). - LeftoverSidebar: removed the per-chip "+ Toevoegen aan dag" and the "Alles +" bulk add; leftovers are now Boek or Negeer only. Auto-open reworked to a render-time state adjustment (no effect) to satisfy the hooks lint rule. Tests added for delete, grid extension, and the no-add sidebar. 1682 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/components/DayTimeline.test.tsx | 24 +++++++++ src/ui/components/DayTimeline.tsx | 61 ++++++++++++++++------ src/ui/components/LeftoverSidebar.test.tsx | 19 ++++--- src/ui/components/LeftoverSidebar.tsx | 35 +++++-------- src/ui/pages/WeekPage.tsx | 30 ++--------- 5 files changed, 94 insertions(+), 75 deletions(-) diff --git a/src/ui/components/DayTimeline.test.tsx b/src/ui/components/DayTimeline.test.tsx index 6b26773..de60cfd 100644 --- a/src/ui/components/DayTimeline.test.tsx +++ b/src/ui/components/DayTimeline.test.tsx @@ -136,6 +136,30 @@ describe('DayTimeline', () => { expect(onConceptClick).toHaveBeenCalledWith(concept) }) + it('deletes a concept from the timeline via the ✕ when onDeleteConcept is given', () => { + const onDeleteConcept = vi.fn() + const concept = makeConcept() + render() + fireEvent.click(screen.getByTitle('Verwijderen uit dag')) + expect(onDeleteConcept).toHaveBeenCalledWith(concept) + }) + + it('shows no delete control when onDeleteConcept is absent or read-only', () => { + const concept = makeConcept() + const { rerender } = render() + expect(screen.queryByTitle('Verwijderen uit dag')).not.toBeInTheDocument() + rerender() + expect(screen.queryByTitle('Verwijderen uit dag')).not.toBeInTheDocument() + }) + + it('extends the grid past 18:00 to fit a late block', () => { + const late = makeConcept({ blockName: 'Late', startTime: '18:30', endTime: '19:15' }) + render() + // hour labels now include 18 and 19 (grid extended), not just up to 17 + expect(screen.getByText('18')).toBeInTheDocument() + expect(screen.getByText('19')).toBeInTheDocument() + }) + it('shows concept header summary with pending count (plural)', () => { const concepts = [ makeConcept({ blockName: 'A', projectId: undefined, serviceId: undefined, startTime: '08:00', endTime: '09:00' }), diff --git a/src/ui/components/DayTimeline.tsx b/src/ui/components/DayTimeline.tsx index a1a057d..d3ed9e7 100644 --- a/src/ui/components/DayTimeline.tsx +++ b/src/ui/components/DayTimeline.tsx @@ -10,8 +10,8 @@ import { useAppStore } from '../../store/appStore' import { pixelToMinutes, snapToInterval, minutesToTime, swapIfNeeded } from './DragOverlay' import EvidencePanel from './EvidencePanel' -const DAY_START = '08:00' -const DAY_END = '18:00' +const DEFAULT_START_HOUR = 8 +const DEFAULT_END_HOUR = 18 const HOUR_HEIGHT_PX = 80 function timeToMinutes(time: string): number { @@ -51,6 +51,7 @@ interface Props { onBookSuggestion: (suggestion: HourEntrySuggestion) => void onEditEntry: (entry: HourEntry) => void onConceptClick?: (block: ClassifiedBlock) => void + onDeleteConcept?: (block: ClassifiedBlock) => void onUploadCsv?: (csvContent: string) => void isClassifying?: boolean onDragNew?: (startTime: string, endTime: string) => void @@ -70,6 +71,7 @@ export function DayTimeline({ onBookSuggestion, onEditEntry, onConceptClick, + onDeleteConcept, onUploadCsv, isClassifying = false, onDragNew, @@ -80,15 +82,32 @@ export function DayTimeline({ const fileInputRef = useRef(null) const blocksContainerRef = useRef(null) + // Dynamic day extent: the grid grows to fit blocks that run past 18:00 (e.g. + // overflow work, late meetings) or start before 08:00, so nothing is clipped + // off the visible timeline. Defaults to 08:00–18:00 when there's nothing later. + const pad2 = (n: number) => String(n).padStart(2, '0') + const blockTimes = [ + ...entries.flatMap((e) => [e.startTime, e.endTime]), + ...conceptBlocks.flatMap((b) => [b.startTime, b.endTime]), + ].filter((t): t is string => Boolean(t)) + const startHour = Math.max(0, Math.min(DEFAULT_START_HOUR, ...blockTimes.map((t) => Math.floor(timeToMinutes(t) / 60)))) + const endHour = Math.min(24, Math.max(DEFAULT_END_HOUR, ...blockTimes.map((t) => Math.ceil(timeToMinutes(t) / 60)))) + const numHours = Math.max(1, endHour - startHour) + const DAY_START = `${pad2(startHour)}:00` + const DAY_END = `${pad2(endHour)}:00` + const DAY_START_MIN = startHour * 60 + const TOTAL_MINS = numHours * 60 + const TOTAL_PX = HOUR_HEIGHT_PX * numHours + // Drag-to-book state const [dragState, setDragState] = useState<{ startMin: number; endMin: number } | null>(null) const isDragging = useRef(false) const getMinutesFromEvent = useCallback((e: MouseEvent) => { const rect = blocksContainerRef.current!.getBoundingClientRect() - const y = Math.max(0, Math.min(e.clientY - rect.top, HOUR_HEIGHT_PX * 10)) - return snapToInterval(pixelToMinutes(y, HOUR_HEIGHT_PX * 10, 8 * 60), 30) - }, []) + const y = Math.max(0, Math.min(e.clientY - rect.top, TOTAL_PX)) + return snapToInterval(pixelToMinutes(y, TOTAL_PX, DAY_START_MIN), 30) + }, [TOTAL_PX, DAY_START_MIN]) function handleBlocksMouseDown(e: React.MouseEvent) { if (!onDragNew) return @@ -151,10 +170,6 @@ export function DayTimeline({ ? mergeConceptsIntoTimeline(entries, conceptBlocks, DAY_START, DAY_END) : computeTimelineBlocks(entries, suggestions, DAY_START, DAY_END) - const DAY_START_MIN = timeToMinutes(DAY_START) - const TOTAL_MINS = timeToMinutes(DAY_END) - DAY_START_MIN - const TOTAL_PX = HOUR_HEIGHT_PX * 10 - function blockTop(startTime: string): number { return ((timeToMinutes(startTime) - DAY_START_MIN) / TOTAL_MINS) * TOTAL_PX } @@ -222,19 +237,30 @@ export function DayTimeline({ const badgeLabel = block.block.origin === 'cache' ? 'Cache' : `${block.block.confidence}/5` + const canDelete = !readOnly && onDeleteConcept !== undefined return ( +
+ {canDelete && ( + + )} +
) } @@ -478,7 +505,7 @@ export function DayTimeline({
{/* Uurlabels */}
- {Array.from({ length: 10 }, (_, i) => i + 8).map((hour) => ( + {Array.from({ length: numHours }, (_, i) => i + startHour).map((hour) => (
{ const { start, end } = swapIfNeeded(dragState.startMin, dragState.endMin) - const top = ((start - 8 * 60) / 600) * (HOUR_HEIGHT_PX * 10) - const height = Math.max(1, ((end - start) / 600) * (HOUR_HEIGHT_PX * 10)) + const top = ((start - DAY_START_MIN) / TOTAL_MINS) * TOTAL_PX + const height = Math.max(1, ((end - start) / TOTAL_MINS) * TOTAL_PX) const durationMins = end - start const durationLabel = durationMins >= 60 ? `${durationMins / 60}u` : `${durationMins}m` return ( diff --git a/src/ui/components/LeftoverSidebar.test.tsx b/src/ui/components/LeftoverSidebar.test.tsx index d100595..ab5db25 100644 --- a/src/ui/components/LeftoverSidebar.test.tsx +++ b/src/ui/components/LeftoverSidebar.test.tsx @@ -24,10 +24,8 @@ function leftover(overrides: Record = {}): ClassifiedBlock { function props(overrides: Partial[0]> = {}) { return { leftovers: [leftover()], - onAdd: vi.fn(), onBook: vi.fn(), onDismiss: vi.fn(), - onAddAll: vi.fn(), ...overrides, } } @@ -58,26 +56,27 @@ describe('LeftoverSidebar', () => { expect(screen.getByTitle('Direct boeken')).toBeInTheDocument() }) - it('fires add / book / dismiss for a chip', () => { - const onAdd = vi.fn(), onBook = vi.fn(), onDismiss = vi.fn() + it('fires book / dismiss for a chip (no add action)', () => { + const onBook = vi.fn(), onDismiss = vi.fn() const block = leftover() - render() + render() fireEvent.mouseEnter(screen.getByText('Uren-assistent Gemini fix').closest('div')!.parentElement!) - fireEvent.click(screen.getByTitle('Toevoegen aan dag')) + expect(screen.queryByTitle('Toevoegen aan dag')).not.toBeInTheDocument() // add removed fireEvent.click(screen.getByTitle('Direct boeken')) fireEvent.click(screen.getByTitle('Negeren')) - expect(onAdd).toHaveBeenCalledWith(block) expect(onBook).toHaveBeenCalledWith(block) expect(onDismiss).toHaveBeenCalledWith(block) }) it('collapses to a rail and re-opens', () => { render() + expect(screen.getByTitle('Inklappen')).toBeInTheDocument() fireEvent.click(screen.getByTitle('Inklappen')) - // collapsed: the rail button is present, header bulk action is gone - expect(screen.queryByText('Alles +')).not.toBeInTheDocument() + // collapsed: chips are hidden, the rail toggle is shown + expect(screen.queryByTitle('Inklappen')).not.toBeInTheDocument() + expect(screen.queryByText('Uren-assistent Gemini fix')).not.toBeInTheDocument() fireEvent.click(screen.getByTitle('Niet-geplaatste blokken tonen')) - expect(screen.getByText('Alles +')).toBeInTheDocument() + expect(screen.getByTitle('Inklappen')).toBeInTheDocument() }) it('hides actions when read-only', () => { diff --git a/src/ui/components/LeftoverSidebar.tsx b/src/ui/components/LeftoverSidebar.tsx index dd3ecb4..fddd17a 100644 --- a/src/ui/components/LeftoverSidebar.tsx +++ b/src/ui/components/LeftoverSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import type { ClassifiedBlock } from '../../domain/entities/ClassifiedBlock' import { useAppStore } from '../../store/appStore' @@ -11,10 +11,8 @@ import { useAppStore } from '../../store/appStore' interface Props { leftovers: ClassifiedBlock[] - onAdd: (block: ClassifiedBlock) => void onBook: (block: ClassifiedBlock) => void onDismiss: (block: ClassifiedBlock) => void - onAddAll?: () => void readOnly?: boolean } @@ -43,15 +41,20 @@ function durationLabel(hours: number): string { return `~${String(rounded).replace('.', ',')}u` } -export function LeftoverSidebar({ leftovers, onAdd, onBook, onDismiss, onAddAll, readOnly = false }: Props) { +export function LeftoverSidebar({ leftovers, onBook, onDismiss, readOnly = false }: Props) { const projects = useAppStore(s => s.projects) const [open, setOpen] = useState(true) const [hovered, setHovered] = useState(null) - // Auto-open whenever a fresh set of leftovers appears (e.g. after "Verwerk dag"). - useEffect(() => { + // Auto-open whenever a fresh set of leftovers appears (e.g. after "Verwerk dag"), + // while still letting the user collapse manually. Done by adjusting state during + // render when the leftover set changes (React's recommended pattern — no effect). + const leftoverKey = leftovers.map(l => l.urlPattern).join('|') + const [seenKey, setSeenKey] = useState(leftoverKey) + if (leftoverKey !== seenKey) { + setSeenKey(leftoverKey) if (leftovers.length > 0) setOpen(true) - }, [leftovers.length]) + } if (leftovers.length === 0) return null @@ -88,20 +91,9 @@ export function LeftoverSidebar({ leftovers, onAdd, onBook, onDismiss, onAddAll, Niet geplaatst {leftovers.length}
-
- {!readOnly && onAddAll && ( - - )} - -
+
{/* Chips */} @@ -140,7 +132,6 @@ export function LeftoverSidebar({ leftovers, onAdd, onBook, onDismiss, onAddAll,
{showActions && (
-
diff --git a/src/ui/pages/WeekPage.tsx b/src/ui/pages/WeekPage.tsx index 8f80d60..49014bb 100644 --- a/src/ui/pages/WeekPage.tsx +++ b/src/ui/pages/WeekPage.tsx @@ -212,18 +212,6 @@ export function WeekPage() { return ends.length > 0 ? ends.reduce((a, b) => (a > b ? a : b)) : '09:00' } - // Promote a leftover onto the timeline, appended after the last placed block. - async function handleAddLeftover(block: ClassifiedBlock) { - const start = latestPlacedEnd(historyStore.blocksForDate) - const end = addHoursToTime(start, block.hours) - const updated = historyStore.blocksForDate.map(b => - b.urlPattern === block.urlPattern - ? { ...b, unplaced: false, startTime: start, endTime: end, firstVisitTime: start, lastVisitTime: end } - : b, - ) - await saveBlocksForDate(week.selectedDate, updated) - } - // Book a leftover directly via the normal booking modal (handles missing // project/service and confirms times). Synthetic 00:00 times get a real slot. function handleBookLeftover(block: ClassifiedBlock) { @@ -235,17 +223,9 @@ export function WeekPage() { await historyStore.removeBlock(week.selectedDate, block.urlPattern) } - async function handleAddAllLeftovers() { - let cursor = latestPlacedEnd(historyStore.blocksForDate) - const updated = historyStore.blocksForDate.map(b => ({ ...b })) - for (const b of updated) { - if (!b.unplaced) continue - const end = addHoursToTime(cursor, b.hours) - b.startTime = cursor; b.endTime = end; b.firstVisitTime = cursor; b.lastVisitTime = end - b.unplaced = false - cursor = end - } - await saveBlocksForDate(week.selectedDate, updated) + // Delete a concept block straight from the timeline (no booking modal). + async function handleDeleteConcept(block: ClassifiedBlock) { + await historyStore.removeBlock(week.selectedDate, block.urlPattern) } const { saveBlocksForDate, reloadForDate } = historyStore @@ -593,15 +573,13 @@ export function WeekPage() { onConceptClick={handleConceptClick} isClassifying={isClassifying || isProcessingDay} readOnly={daySubmitted} - {...(daySubmitted ? {} : { onUploadCsv: handleUploadCsv, onDragNew: handleDragNew })} + {...(daySubmitted ? {} : { onUploadCsv: handleUploadCsv, onDragNew: handleDragNew, onDeleteConcept: (b: ClassifiedBlock) => void handleDeleteConcept(b) })} {...(canProcessWeek && !daySubmitted ? { onProcessDay: () => void handleProcessDay(week.selectedDate) } : {})} /> b.unplaced)} - onAdd={b => void handleAddLeftover(b)} onBook={handleBookLeftover} onDismiss={b => void handleDismissLeftover(b)} - onAddAll={() => void handleAddAllLeftovers()} readOnly={daySubmitted} />