) {
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.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..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
@@ -585,7 +565,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}
@@ -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}
/>