Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/ui/components/DayTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DayTimeline {...baseProps({ conceptBlocks: [concept], onDeleteConcept })} />)
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(<DayTimeline {...baseProps({ conceptBlocks: [concept] })} />)
expect(screen.queryByTitle('Verwijderen uit dag')).not.toBeInTheDocument()
rerender(<DayTimeline {...baseProps({ conceptBlocks: [concept], onDeleteConcept: vi.fn(), readOnly: true })} />)
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(<DayTimeline {...baseProps({ conceptBlocks: [late] })} />)
// 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' }),
Expand Down
61 changes: 44 additions & 17 deletions src/ui/components/DayTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -70,6 +71,7 @@ export function DayTimeline({
onBookSuggestion,
onEditEntry,
onConceptClick,
onDeleteConcept,
onUploadCsv,
isClassifying = false,
onDragNew,
Expand All @@ -80,15 +82,32 @@ export function DayTimeline({
const fileInputRef = useRef<HTMLInputElement>(null)
const blocksContainerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) {
if (!onDragNew) return
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -222,19 +237,30 @@ export function DayTimeline({
const badgeLabel = block.block.origin === 'cache'
? 'Cache'
: `${block.block.confidence}/5`
const canDelete = !readOnly && onDeleteConcept !== undefined
return (
<div key={key} className="group" style={baseStyle}>
{canDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDeleteConcept!(block.block) }}
title="Verwijderen uit dag"
className="opacity-0 group-hover:opacity-100 transition-opacity"
style={{ position: 'absolute', top: 2, right: 3, zIndex: 5, width: 18, height: 18, borderRadius: 4, border: 'none', background: 'rgba(255,255,255,.85)', color: '#ef4444', fontSize: 12, fontWeight: 700, lineHeight: 1, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
</button>
)}
<button
key={key}
onClick={readOnly ? undefined : () => onConceptClick?.(block.block)}
style={{
...baseStyle,
width: '100%',
height: '100%',
background: cs.bg,
border: `1.5px ${cs.borderStyle} ${cs.borderColor}`,
borderLeft: `3px solid ${cs.borderLeft}`,
borderRadius: 5,
overflow: 'hidden',
cursor: readOnly ? 'default' : 'pointer',
position: 'absolute',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
Expand All @@ -243,7 +269,7 @@ export function DayTimeline({
}}
>
{!compact && (
<span style={{ background: cs.badgeBg, color: cs.badgeColor, fontSize: 8, fontWeight: 700, borderRadius: 3, padding: '1px 4px', position: 'absolute', top: 3, right: 4 }}>
<span className={canDelete ? 'group-hover:opacity-0 transition-opacity' : ''} style={{ background: cs.badgeBg, color: cs.badgeColor, fontSize: 8, fontWeight: 700, borderRadius: 3, padding: '1px 4px', position: 'absolute', top: 3, right: 4 }}>
{badgeLabel}
</span>
)}
Expand Down Expand Up @@ -271,6 +297,7 @@ export function DayTimeline({
</>
)}
</button>
</div>
)
}

Expand Down Expand Up @@ -478,7 +505,7 @@ export function DayTimeline({
<div className="flex gap-3">
{/* Uurlabels */}
<div className="flex flex-col flex-shrink-0 w-8">
{Array.from({ length: 10 }, (_, i) => i + 8).map((hour) => (
{Array.from({ length: numHours }, (_, i) => i + startHour).map((hour) => (
<div
key={hour}
className="relative flex-shrink-0"
Expand All @@ -502,7 +529,7 @@ export function DayTimeline({
ref={blocksContainerRef}
className="flex-1 relative"
style={{
minHeight: HOUR_HEIGHT_PX * 10,
minHeight: TOTAL_PX,
cursor: onDragNew ? (dragState ? 'ns-resize' : 'crosshair') : undefined,
userSelect: onDragNew ? 'none' : undefined,
}}
Expand All @@ -511,8 +538,8 @@ export function DayTimeline({
{/* Drag preview-blok */}
{dragState && (() => {
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 (
Expand Down
19 changes: 9 additions & 10 deletions src/ui/components/LeftoverSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@ function leftover(overrides: Record<string, unknown> = {}): ClassifiedBlock {
function props(overrides: Partial<Parameters<typeof LeftoverSidebar>[0]> = {}) {
return {
leftovers: [leftover()],
onAdd: vi.fn(),
onBook: vi.fn(),
onDismiss: vi.fn(),
onAddAll: vi.fn(),
...overrides,
}
}
Expand Down Expand Up @@ -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(<LeftoverSidebar {...props({ leftovers: [block], onAdd, onBook, onDismiss })} />)
render(<LeftoverSidebar {...props({ leftovers: [block], onBook, onDismiss })} />)
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(<LeftoverSidebar {...props()} />)
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', () => {
Expand Down
35 changes: 13 additions & 22 deletions src/ui/components/LeftoverSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
}

Expand Down Expand Up @@ -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<string | null>(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

Expand Down Expand Up @@ -88,20 +91,9 @@ export function LeftoverSidebar({ leftovers, onAdd, onBook, onDismiss, onAddAll,
Niet geplaatst
<span style={{ background: '#fef3c7', color: '#d97706', fontSize: 10, fontWeight: 700, borderRadius: 8, padding: '0 6px' }}>{leftovers.length}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{!readOnly && onAddAll && (
<button
onClick={onAddAll}
style={{ fontSize: 9, padding: '3px 7px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg)', color: 'var(--text-secondary)', cursor: 'pointer' }}
title="Alles aan de dag toevoegen"
>
Alles +
</button>
)}
<button onClick={() => setOpen(false)} title="Inklappen" style={{ fontSize: 13, color: 'var(--text-muted)', cursor: 'pointer', padding: '0 4px', background: 'none', border: 'none' }}>
»
</button>
</div>
<button onClick={() => setOpen(false)} title="Inklappen" style={{ fontSize: 13, color: 'var(--text-muted)', cursor: 'pointer', padding: '0 4px', background: 'none', border: 'none' }}>
»
</button>
</div>

{/* Chips */}
Expand Down Expand Up @@ -140,7 +132,6 @@ export function LeftoverSidebar({ leftovers, onAdd, onBook, onDismiss, onAddAll,
</div>
{showActions && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
<button onClick={() => onAdd(block)} title="Toevoegen aan dag" style={iconBtn('#4f46e5', '#eef2ff')}>+</button>
<button onClick={() => onBook(block)} title="Direct boeken" style={iconBtn('#fff', '#16a34a', true)}>✓</button>
<button onClick={() => onDismiss(block)} title="Negeren" style={iconBtn('var(--text-muted)', 'var(--bg)')}>✕</button>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/ui/pages/WeekPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
32 changes: 5 additions & 27 deletions src/ui/pages/WeekPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -585,23 +565,21 @@ 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}
onEditEntry={handleEditEntry}
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) } : {})}
/>
<LeftoverSidebar
leftovers={historyStore.blocksForDate.filter(b => b.unplaced)}
onAdd={b => void handleAddLeftover(b)}
onBook={handleBookLeftover}
onDismiss={b => void handleDismissLeftover(b)}
onAddAll={() => void handleAddAllLeftovers()}
readOnly={daySubmitted}
/>
</div>
Expand Down
Loading