diff --git a/app/src/routes/_layout/time-tracking/-components/active-timer-block.tsx b/app/src/routes/_layout/time-tracking/-components/active-timer-block.tsx index a11a950..6c3268c 100644 --- a/app/src/routes/_layout/time-tracking/-components/active-timer-block.tsx +++ b/app/src/routes/_layout/time-tracking/-components/active-timer-block.tsx @@ -11,7 +11,6 @@ import { import { buildTimelineCardText } from "./timeline-card-text"; const HOURS_PER_DAY = 24; -const ACTIVE_TIMER_MIN_VISIBLE_PX = 10; export type ActiveTimerHourBounds = { earliestHour: number; @@ -41,9 +40,11 @@ export type ActiveTimerDayInterval = { export function ActiveTimerBlock({ segment, isWeekView, + dayContentWidthPx, }: { segment: ActiveTimerSegment; isWeekView: boolean; + dayContentWidthPx: number | null; }) { const text = buildTimelineCardText({ projectName: segment.projectName, @@ -53,6 +54,8 @@ export function ActiveTimerBlock({ const timeRangeLabel = `${format(segment.segmentStart, "HH:mm")} — ${ segment.isCurrent ? "Now" : format(segment.segmentEnd, "HH:mm") }`; + const cardWidthPx = + dayContentWidthPx === null ? null : Math.max(0, dayContentWidthPx - 12); return ( @@ -74,6 +77,7 @@ export function ActiveTimerBlock({ = 92 ? "full" : "project_primary_duration"; + } + if (widthPx < 250) { + if (heightPx < 44) return "project_primary"; + return heightPx >= 96 ? "full" : "project_primary_duration"; + } + if (widthPx < 320 && heightPx < 96) { + return "project_primary_duration"; + } + } + + // In roomier columns, allow notes earlier so ~30 minute entries can show + // useful context while still reserving full mode for taller cards. + if (heightPx < 64) { + return "project_primary"; + } + if (heightPx < 96) { + return "project_primary_duration"; + } + return "full"; +} + function TimelineCardBodyBase({ text, heightPx, + widthPx, color, hours, projectColor, className, + forceProjectOnly, config, }: { text: TimelineCardText; heightPx: number; + widthPx: number | null; color: string; hours: number; projectColor?: string; className?: string; + forceProjectOnly?: boolean; config: BodyConfig; }) { - const isCompact = config.compactAt !== undefined && heightPx < config.compactAt; - const showProjectLabel = heightPx >= config.showProjectAt; - const showPrimaryDetail = heightPx > config.showPrimaryAt; - const showSecondaryDetail = text.hasNote && heightPx > config.showSecondaryAt; - const showDuration = heightPx >= config.showDurationAt; - const showTwoLinePrimary = heightPx >= config.twoLinePrimaryAt; + const densityMode = resolveDensityMode({ + heightPx, + widthPx, + forceProjectOnly, + }); + const isCompact = + densityMode === "project_only" || + densityMode === "project_duration" || + densityMode === "project_primary"; + const showPrimaryDetail = hasPrimaryDetail(densityMode); + const showSecondaryDetail = densityMode === "full" && text.hasNote; + const showDuration = hasDuration(densityMode); + const showTwoLinePrimary = + densityMode === "full" && + heightPx >= 132 && + (widthPx === null || widthPx >= 380); return (
- {showProjectLabel && ( -

- {text.projectLabel} -

- )} +

+ {text.projectLabel} +

{showPrimaryDetail && (

); @@ -203,6 +277,7 @@ export function SavedTimelineCardBody({ export function ActiveTimelineCardBody({ text, heightPx, + widthPx, color, hours, isWeekView, @@ -211,6 +286,7 @@ export function ActiveTimelineCardBody({ }: { text: TimelineCardText; heightPx: number; + widthPx: number | null; color: string; hours: number; isWeekView: boolean; @@ -221,6 +297,7 @@ export function ActiveTimelineCardBody({ { + entry.column = 0; + entry.totalColumns = 1; + }); + return; + } + + const sorted = [...entries].sort((a, b) => a.startMs - b.startMs || b.endMs - a.endMs); + + // Build transitive overlap clusters. + const clusterByIndex = new Map(); + let nextCluster = 0; + + for (let i = 0; i < sorted.length; i++) { + let cluster = -1; + for (let j = 0; j < i; j++) { + if (!overlaps(sorted[j], sorted[i])) continue; + const jCluster = clusterByIndex.get(j)!; + if (cluster === -1) { + cluster = jCluster; + continue; + } + + if (cluster !== jCluster) { + const oldCluster = jCluster; + for (const [idx, candidate] of clusterByIndex) { + if (candidate === oldCluster) clusterByIndex.set(idx, cluster); + } + } + } + + clusterByIndex.set(i, cluster === -1 ? nextCluster++ : cluster); + } + + const membersByCluster = new Map(); + for (const [idx, cluster] of clusterByIndex) { + const members = membersByCluster.get(cluster) ?? []; + members.push(idx); + membersByCluster.set(cluster, members); + } + + for (const members of membersByCluster.values()) { + if (members.length === 1) { + const only = sorted[members[0]]; + only.column = 0; + only.totalColumns = 1; + continue; + } + + members.sort((a, b) => { + const startDelta = sorted[a].startMs - sorted[b].startMs; + if (startDelta !== 0) return startDelta; + return sorted[b].endMs - sorted[a].endMs; + }); + + const assigned: number[] = []; + + for (const idx of members) { + const used = new Set(); + for (const otherIdx of assigned) { + if (overlaps(sorted[idx], sorted[otherIdx])) { + used.add(sorted[otherIdx].column); + } + } + + let column = 0; + while (used.has(column)) column++; + sorted[idx].column = column; + assigned.push(idx); + } + + const totalColumns = + Math.max(...members.map((member) => sorted[member].column)) + 1; + + for (const idx of members) { + sorted[idx].totalColumns = totalColumns; + } + } +} + +function assignMicroLanes( + entries: TimelineLaidOutEntry[], + microMaxLanes: number, + microCardHeightPx: number, +) { + const laneBottomByColumn = new Map(); + + const microEntries = entries + .filter((entry) => entry.visualVariant === "micro") + .sort((a, b) => a.topPx - b.topPx || a.startMs - b.startMs); + + for (const entry of microEntries) { + const key = `${entry.column}/${entry.totalColumns}`; + const laneBottoms = + laneBottomByColumn.get(key) ?? + Array.from({ length: microMaxLanes }, () => Number.NEGATIVE_INFINITY); + + let lane = laneBottoms.findIndex((bottom) => bottom <= entry.topPx); + if (lane === -1) lane = microMaxLanes - 1; + + laneBottoms[lane] = Math.max( + laneBottoms[lane], + entry.topPx + microCardHeightPx, + ); + entry.microLane = lane; + laneBottomByColumn.set(key, laneBottoms); + } +} + +export function layoutDayEntries({ + dayEntries, + gridStartHour, + gridEndHour, + hourHeightPx, + shortEntryThresholdPx = SHORT_ENTRY_THRESHOLD_PX, + microCardHeightPx = MICRO_CARD_HEIGHT_PX, + microMaxLanes = MICRO_MAX_LANES, +}: { + dayEntries: TimeEntry[]; + gridStartHour: number; + gridEndHour: number; + hourHeightPx: number; + shortEntryThresholdPx?: number; + microCardHeightPx?: number; + microMaxLanes?: number; +}): TimelineLaidOutEntry[] { + if (dayEntries.length === 0) return []; + + const gridHeight = Math.max(0, (gridEndHour - gridStartHour) * hourHeightPx); + if (gridHeight === 0) return []; + + const entries: TimelineLaidOutEntry[] = []; + + for (const entry of dayEntries) { + if (!entry.startTime || !entry.endTime) continue; + + const start = parseISO(entry.startTime); + const end = parseISO(entry.endTime); + const startMs = start.getTime(); + const endMs = end.getTime(); + + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) { + continue; + } + + const topPx = clamp( + (getHourInDay(start) - gridStartHour) * hourHeightPx, + 0, + gridHeight, + ); + const bottomPx = clamp( + (getHourInDay(end) - gridStartHour) * hourHeightPx, + 0, + gridHeight, + ); + + if (bottomPx <= topPx) continue; + + const durationPx = Math.max(1, bottomPx - topPx); + const visualVariant = + durationPx < shortEntryThresholdPx ? "micro" : "full"; + + entries.push({ + ...entry, + startMs, + endMs, + topPx, + bottomPx, + durationPx, + visualVariant, + visualHeightPx: + visualVariant === "micro" ? microCardHeightPx : durationPx, + microLane: 0, + column: 0, + totalColumns: 1, + }); + } + + if (entries.length === 0) return []; + + assignColumns(entries); + assignMicroLanes(entries, microMaxLanes, microCardHeightPx); + + return entries.sort((a, b) => a.topPx - b.topPx || a.startMs - b.startMs); +} diff --git a/app/src/routes/_layout/time-tracking/-components/timeline-view.tsx b/app/src/routes/_layout/time-tracking/-components/timeline-view.tsx index 1f57fd1..d6bd7f0 100644 --- a/app/src/routes/_layout/time-tracking/-components/timeline-view.tsx +++ b/app/src/routes/_layout/time-tracking/-components/timeline-view.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from "react"; +import React, { useMemo, useState, useEffect, useRef } from "react"; import { AttestLevel, TimeEntry, @@ -40,6 +40,11 @@ import { SavedTimelineCardTooltipBody, } from "./timeline-card-content"; import { buildTimelineCardText } from "./timeline-card-text"; +import { + MICRO_LANE_X_OFFSET_PX, + layoutDayEntries, + type TimelineLaidOutEntry, +} from "./timeline-layout"; type TimelineViewProps = { timeEntries: TimeEntry[]; @@ -51,13 +56,13 @@ type TimelineMode = "day" | "week"; const DEFAULT_START_HOUR = 8; const DEFAULT_END_HOUR = 17; const MIN_VISIBLE_END_HOUR = 20; -const HOUR_HEIGHT_PX = 80; -const MIN_BLOCK_PX = 36; +const HOUR_HEIGHT_PX = 96; const TIMELINE_CHROME_HEIGHT_PX = 80; const DAY_KEY_FORMAT = "yyyy-MM-dd"; const WEEK_STARTS_ON = 1 as const; const DAY_HEADER_LAYOUT_CLASS = "flex flex-col items-center gap-0.5 px-2 py-2.5"; const DAY_HEADER_TOTAL_CLASS = "time-display text-[11px] text-muted-foreground"; +const BLOCK_SIDE_INSET_PX = 6; function toDayKey(date: Date) { return format(date, DAY_KEY_FORMAT); @@ -70,130 +75,6 @@ function getInRangeDate(fromIso: string, toIso: string) { return today >= from && today <= to ? today : from; } -type PositionedEntry = TimeEntry & { - topPx: number; - heightPx: number; - endPx: number; // actual time-based end position (without MIN_BLOCK_PX inflation) - column: number; - totalColumns: number; -}; - -function positionEntries( - dayEntries: TimeEntry[], - gridStartHour: number, -): PositionedEntry[] { - if (dayEntries.length === 0) return []; - - const positioned: PositionedEntry[] = []; - - dayEntries.forEach((entry) => { - if (!entry.startTime || !entry.endTime) return; - const start = parseISO(entry.startTime); - const end = parseISO(entry.endTime); - const startH = start.getHours() + start.getMinutes() / 60 - gridStartHour; - const endH = end.getHours() + end.getMinutes() / 60 - gridStartHour; - - const naturalHeight = (endH - startH) * HOUR_HEIGHT_PX; - const topPx = Math.max(0, startH * HOUR_HEIGHT_PX); - positioned.push({ - ...entry, - topPx, - heightPx: Math.max(MIN_BLOCK_PX, naturalHeight), - endPx: Math.max(0, endH * HOUR_HEIGHT_PX), - column: 0, - totalColumns: 1, - }); - }); - - if (positioned.length === 0) return []; - - // Resolve overlaps using cluster-based approach - const sorted = positioned.sort((a, b) => a.topPx - b.topPx); - - // Build overlap clusters: groups of entries that transitively overlap - const clusterOf = new Map(); // index -> cluster id - let nextCluster = 0; - - for (let i = 0; i < sorted.length; i++) { - let cluster = -1; - for (let j = 0; j < i; j++) { - if ( - sorted[j].endPx > sorted[i].topPx && - sorted[j].topPx < sorted[i].endPx - ) { - const jCluster = clusterOf.get(j)!; - if (cluster === -1) { - cluster = jCluster; - } else if (cluster !== jCluster) { - // Merge clusters - const oldCluster = jCluster; - for (const [idx, c] of clusterOf) { - if (c === oldCluster) clusterOf.set(idx, cluster); - } - } - } - } - clusterOf.set(i, cluster === -1 ? nextCluster++ : cluster); - } - - // Group entries by cluster - const clusters = new Map(); - for (const [idx, c] of clusterOf) { - const arr = clusters.get(c) || []; - arr.push(idx); - clusters.set(c, arr); - } - - // Assign columns within each cluster - for (const members of clusters.values()) { - if (members.length <= 1) continue; - members.sort((a, b) => sorted[a].topPx - sorted[b].topPx); - for (const idx of members) { - const usedColumns = new Set(); - for (const other of members) { - if ( - other !== idx && - sorted[other].topPx < sorted[idx].endPx && - sorted[other].endPx > sorted[idx].topPx - ) { - usedColumns.add(sorted[other].column); - } - } - let col = 0; - while (usedColumns.has(col)) col++; - sorted[idx].column = col; - } - const maxCol = Math.max(...members.map((idx) => sorted[idx].column)) + 1; - for (const idx of members) { - sorted[idx].totalColumns = maxCol; - } - } - - // Push-down pass: prevent visual overlap between entries that share horizontal space. - // MIN_BLOCK_PX can inflate short entries beyond their actual end time. Entries from - // different clusters can share horizontal space (e.g. full-width entry above a - // half-width clustered entry), so we check actual horizontal range overlap. - for (let i = 1; i < sorted.length; i++) { - const curr = sorted[i]; - const currLeft = curr.column / curr.totalColumns; - const currRight = (curr.column + 1) / curr.totalColumns; - let maxBottom = 0; - for (let j = 0; j < i; j++) { - const prev = sorted[j]; - const prevLeft = prev.column / prev.totalColumns; - const prevRight = (prev.column + 1) / prev.totalColumns; - if (prevLeft < currRight && currLeft < prevRight) { - maxBottom = Math.max(maxBottom, prev.topPx + prev.heightPx); - } - } - if (maxBottom > curr.topPx) { - curr.topPx = maxBottom; - } - } - - return sorted; -} - /** Scan entries to find the earliest start and latest end, with 30 min padding */ function computeGridBounds( entries: TimeEntry[], @@ -277,18 +158,17 @@ function HourGridLines({ function NowIndicator({ date, startHour }: { date: Date; startHour: number }) { const [now, setNow] = useState(() => new Date()); + const isTodayColumn = isToday(date); useEffect(() => { - if (!isToday(date)) return; + if (!isTodayColumn) return; + // Depend on stable day-identity boolean so interval isn't restarted by recreated Date objects. const id = setInterval(() => setNow(new Date()), 60_000); return () => clearInterval(id); - }, [date]); - - if (!isToday(date)) return null; + }, [isTodayColumn]); const currentHour = now.getHours() + now.getMinutes() / 60 - startHour; - - if (currentHour < 0) return null; + if (!isTodayColumn || currentHour < 0) return null; return (

void; }) { + const isMicro = entry.visualVariant === "micro"; const columnWidth = 100 / entry.totalColumns; const leftPercent = entry.column * columnWidth; + const microOffsetPx = isMicro ? entry.microLane * MICRO_LANE_X_OFFSET_PX : 0; + const blockLeftInsetPx = BLOCK_SIDE_INSET_PX + microOffsetPx; + const blockWidthValue = isMicro + ? `max(34px, calc(${columnWidth}% - ${BLOCK_SIDE_INSET_PX * 2 + microOffsetPx}px))` + : `calc(${columnWidth}% - ${BLOCK_SIDE_INSET_PX * 2}px)`; + const blockWidthPx = + dayContentWidthPx === null + ? null + : Math.max( + isMicro ? 34 : 0, + dayContentWidthPx / entry.totalColumns - + (BLOCK_SIDE_INSET_PX * 2 + microOffsetPx), + ); const startTime = entry.startTime ? format(parseISO(entry.startTime), "HH:mm") @@ -398,33 +294,53 @@ const TimelineBlock = React.memo(function TimelineBlock({ transition={{ duration: 0.25 }} onClick={isEditable ? onClick : undefined} className={cn( - "group/block absolute z-10 overflow-hidden rounded-lg border transition-all duration-200", + "group/block absolute z-10 rounded-lg border transition-all duration-200", isEditable ? "cursor-pointer hover:z-30 hover:shadow-lg" : "cursor-default", + isMicro ? "overflow-visible" : "overflow-hidden", )} style={{ top: entry.topPx, - height: entry.heightPx, - left: `calc(${leftPercent}% + 6px)`, - width: `calc(${columnWidth}% - 12px)`, + height: entry.visualHeightPx, + left: `calc(${leftPercent}% + ${blockLeftInsetPx}px)`, + width: blockWidthValue, backgroundColor: withAlpha(color, 0.18), borderColor: withAlpha(color, 0.4), boxShadow: `0 1px 4px ${withAlpha(color, 0.15)}`, }} > + {isMicro && ( + <> +
+
+ + )}
- {/* Play button overlay on hover */} - + {!isMicro && } @@ -457,7 +373,7 @@ function DayColumn({ onEntryClick, }: { date: Date; - entries: PositionedEntry[]; + entries: TimelineLaidOutEntry[]; colorMap: Map; gridHeight: number; startHour: number; @@ -465,11 +381,31 @@ function DayColumn({ isWeekView: boolean; isOnly: boolean; activeTimerSegment?: ActiveTimerSegment | null; - onEntryClick: (entry: PositionedEntry) => void; + onEntryClick: (entry: TimelineLaidOutEntry) => void; }) { const dayTotal = entries.reduce((sum, e) => sum + e.hours, 0); const today = isToday(date); const hasDayTotal = dayTotal > 0; + const timelineAreaRef = useRef(null); + const [dayContentWidthPx, setDayContentWidthPx] = useState(null); + + useEffect(() => { + const element = timelineAreaRef.current; + if (!element) return; + + const updateWidth = () => { + const next = element.getBoundingClientRect().width; + setDayContentWidthPx(Number.isFinite(next) ? next : null); + }; + + updateWidth(); + + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(() => updateWidth()); + observer.observe(element); + return () => observer.disconnect(); + }, []); return (
{/* Timeline area — explicit height from computed grid bounds */} -
+
{activeTimerSegment && ( - + )} {entries.map((entry) => ( Array.from({ length: 7 }, (_, index) => addDays(currentWeekStart, index)), + [currentWeekStart], + ); + const weekDays = useMemo(() => { - const weekCandidates = Array.from({ length: 7 }, (_, index) => - addDays(currentWeekStart, index), - ); const activeTimerWeekIntervalsByDay = buildActiveTimerIntervalsByDay({ timer: normalizedActiveTimer, now: activeTimerNow, @@ -630,7 +573,7 @@ export function TimelineView({ timeEntries, dateRange }: TimelineViewProps) { activeTimerWeekIntervalsByDay.has(key) ); }); - }, [currentWeekStart, entriesByDate, normalizedActiveTimer, activeTimerNow]); + }, [entriesByDate, normalizedActiveTimer, activeTimerNow, weekCandidates]); // Multi-week navigation const isMultiWeek = useMemo(() => { @@ -711,14 +654,22 @@ export function TimelineView({ timeEntries, dateRange }: TimelineViewProps) { ); const positionedByDay = useMemo(() => { - const map = new Map(); + const map = new Map(); visibleDays.forEach((day) => { const key = toDayKey(day); const dayEntries = entriesByDate.get(key) ?? []; - map.set(key, positionEntries(dayEntries, startHour)); + map.set( + key, + layoutDayEntries({ + dayEntries, + gridStartHour: startHour, + gridEndHour: endHour, + hourHeightPx: HOUR_HEIGHT_PX, + }), + ); }); return map; - }, [entriesByDate, visibleDays, startHour]); + }, [entriesByDate, visibleDays, startHour, endHour]); const totalHours = endHour - startHour; const gridHeight = totalHours * HOUR_HEIGHT_PX; @@ -752,7 +703,7 @@ export function TimelineView({ timeEntries, dateRange }: TimelineViewProps) { return total; }, [positionedByDay]); - const handleEntryClick = (entry: PositionedEntry) => { + const handleEntryClick = (entry: TimelineLaidOutEntry) => { if (entry.attestLevel !== AttestLevel.None) return; setEditingEntry(entry); };