1- import type { DutyStatus , LogSheet } from "../types/api" ;
1+ import type { DutyStatus , LogSheet , TimelineEvent } from "../types/api" ;
2+ import { formatHM } from "../utils/formatDuration" ;
23
34interface TimelineViewProps {
45 logSheets : LogSheet [ ] ;
@@ -18,6 +19,10 @@ const statusLabels: Record<DutyStatus, string> = {
1819 on_duty : "On-Duty" ,
1920} ;
2021
22+ const REST_STATUSES : DutyStatus [ ] = [ "off_duty" , "sleeper_berth" ] ;
23+ const DAY_MINUTES = 24 * 60 ;
24+ const MIN_VISUAL_MINUTES = 15 ;
25+
2126function minuteOfDay ( isoString : string ) : number {
2227 const match = isoString . match ( / T ( \d { 2 } ) : ( \d { 2 } ) / ) ;
2328 if ( match ) {
@@ -42,12 +47,78 @@ function formatDuration(minutes: number): string {
4247 return `${ hrs } h ${ mins } m` ;
4348}
4449
50+ function isRestStatus ( status : DutyStatus ) : boolean {
51+ return REST_STATUSES . includes ( status ) ;
52+ }
53+
54+ function eventRange ( event : TimelineEvent ) : { start : number ; end : number } {
55+ const start = minuteOfDay ( event . start_time ) ;
56+ const endRaw = minuteOfDay ( event . end_time ) ;
57+ const end = endRaw <= start ? DAY_MINUTES : endRaw ;
58+ return { start, end } ;
59+ }
60+
61+ function trailingMidnightRestMinutes ( sheet : LogSheet ) : number {
62+ let total = 0 ;
63+ let expectedEnd = DAY_MINUTES ;
64+
65+ for ( let index = sheet . events . length - 1 ; index >= 0 ; index -= 1 ) {
66+ const event = sheet . events [ index ] ;
67+ const { start, end } = eventRange ( event ) ;
68+
69+ // Only count a single contiguous block that directly touches midnight.
70+ if ( end !== expectedEnd || ! isRestStatus ( event . status ) ) break ;
71+
72+ total += end - start ;
73+ expectedEnd = start ;
74+ }
75+
76+ return total ;
77+ }
78+
79+ function leadingMidnightRestMinutes ( sheet : LogSheet ) : number {
80+ let total = 0 ;
81+ let expectedStart = 0 ;
82+
83+ for ( const event of sheet . events ) {
84+ const { start, end } = eventRange ( event ) ;
85+
86+ // Only count a single contiguous block that begins at midnight.
87+ if ( start !== expectedStart || ! isRestStatus ( event . status ) ) break ;
88+
89+ total += end - start ;
90+ expectedStart = end ;
91+ }
92+
93+ return total ;
94+ }
95+
96+ function boundaryContinuousRestMinutes ( current : LogSheet , next : LogSheet ) : number {
97+ const trailing = trailingMidnightRestMinutes ( current ) ;
98+ const leading = leadingMidnightRestMinutes ( next ) ;
99+
100+ // A boundary-spanning continuous rest requires rest touching midnight
101+ // on BOTH sides of the day boundary.
102+ if ( trailing === 0 || leading === 0 ) return 0 ;
103+ return trailing + leading ;
104+ }
105+
106+ function restPillClasses ( totalRestMinutes : number ) : string {
107+ if ( totalRestMinutes >= 34 * 60 ) {
108+ return "border-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-300" ;
109+ }
110+ if ( totalRestMinutes >= 10 * 60 ) {
111+ return "border-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-300" ;
112+ }
113+ return "border-red-200 bg-red-50 text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-300" ;
114+ }
115+
45116function dayRangePercent ( start : string , end : string ) : { left : number ; width : number } {
46117 const startMinutes = minuteOfDay ( start ) ;
47118 const endRaw = minuteOfDay ( end ) ;
48- const endMinutes = endRaw <= startMinutes ? 24 * 60 : endRaw ;
49- const left = ( startMinutes / ( 24 * 60 ) ) * 100 ;
50- const width = ( Math . max ( endMinutes - startMinutes , 15 ) / ( 24 * 60 ) ) * 100 ;
119+ const endMinutes = endRaw <= startMinutes ? DAY_MINUTES : endRaw ;
120+ const left = ( startMinutes / DAY_MINUTES ) * 100 ;
121+ const width = ( Math . max ( endMinutes - startMinutes , MIN_VISUAL_MINUTES ) / DAY_MINUTES ) * 100 ;
51122 return { left, width } ;
52123}
53124
@@ -77,55 +148,74 @@ export function TimelineView({ logSheets }: TimelineViewProps) {
77148 </ div >
78149
79150 < div className = "space-y-6" >
80- { logSheets . map ( ( sheet ) => (
81- < article
82- key = { sheet . date }
83- className = "rounded-lg border border-gray-200 bg-gray-50/40 p-4 dark:border-gray-700 dark:bg-gray-900/40"
84- >
85- < h3 className = "mb-3 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300" >
86- { sheet . date }
87- </ h3 >
88-
89- < div className = "relative h-14 rounded-md border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900" >
90- { sheet . events . map ( ( event , index ) => {
91- const { left, width } = dayRangePercent ( event . start_time , event . end_time ) ;
92- const start = minuteOfDay ( event . start_time ) ;
93- const endRaw = minuteOfDay ( event . end_time ) ;
94- const end = endRaw <= start ? 24 * 60 : endRaw ;
95- const startLabel = start === 0 ? "Midnight" : formatClock ( start ) ;
96- const endLabel = end === 24 * 60 ? "Midnight" : formatClock ( end ) ;
97- return (
98- < div
99- key = { `${ event . start_time } -${ index } ` }
100- className = { `absolute top-2 h-10 cursor-pointer rounded-sm transition-all duration-200 hover:-translate-y-0.5 hover:scale-y-110 hover:shadow-md ${ statusColors [ event . status ] } ` }
101- style = { { left : `${ left } %` , width : `${ width } %` } }
102- title = { `${ statusLabels [ event . status ] } : ${ startLabel } - ${ endLabel } (${ formatDuration ( end - start ) } )` }
103- />
104- ) ;
105- } ) }
106- </ div >
151+ { logSheets . map ( ( sheet , index ) => {
152+ const nextSheet = logSheets [ index + 1 ] ;
153+ const restAcrossBoundary = nextSheet ? boundaryContinuousRestMinutes ( sheet , nextSheet ) : 0 ;
154+ const restart34Met = restAcrossBoundary >= 34 * 60 ;
155+ const restResetMet = restAcrossBoundary >= 10 * 60 ;
156+
157+ return (
158+ < div key = { sheet . date } className = "space-y-3" >
159+ < article className = "rounded-lg border border-gray-200 bg-gray-50/40 p-4 dark:border-gray-700 dark:bg-gray-900/40" >
160+ < h3 className = "mb-3 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300" >
161+ { sheet . date }
162+ </ h3 >
107163
108- < div className = "mt-3 grid grid-cols-2 gap-2 text-xs text-gray-700 md:grid-cols-4 dark:text-gray-300" >
109- < p >
110- < span className = "font-semibold text-gray-900 dark:text-gray-100" > Driving:</ span > { " " }
111- { ( sheet . totals . driving ?? 0 ) . toFixed ( 2 ) } h
112- </ p >
113- < p >
114- < span className = "font-semibold text-gray-900 dark:text-gray-100" > On-duty:</ span > { " " }
115- { ( sheet . totals . on_duty ?? 0 ) . toFixed ( 2 ) } h
116- </ p >
117- < p >
118- < span className = "font-semibold text-gray-900 dark:text-gray-100" > Off-duty:</ span > { " " }
119- { ( sheet . totals . off_duty ?? 0 ) . toFixed ( 2 ) } h
120- </ p >
121- < p >
122- < span className = "font-semibold text-gray-900 dark:text-gray-100" > Sleeper:</ span > { " " }
123- { ( sheet . totals . sleeper_berth ?? 0 ) . toFixed ( 2 ) } h
124- </ p >
164+ < div className = "relative h-14 rounded-md border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900" >
165+ { sheet . events . map ( ( event , eventIndex ) => {
166+ const { left, width } = dayRangePercent ( event . start_time , event . end_time ) ;
167+ const { start, end } = eventRange ( event ) ;
168+ const startLabel = start === 0 ? "Midnight" : formatClock ( start ) ;
169+ const endLabel = end === DAY_MINUTES ? "Midnight" : formatClock ( end ) ;
170+ return (
171+ < div
172+ key = { `${ event . start_time } -${ eventIndex } ` }
173+ className = { `absolute top-2 h-10 cursor-pointer rounded-sm transition-all duration-200 hover:-translate-y-0.5 hover:scale-y-110 hover:shadow-md ${ statusColors [ event . status ] } ` }
174+ style = { { left : `${ left } %` , width : `${ width } %` } }
175+ title = { `${ statusLabels [ event . status ] } : ${ startLabel } - ${ endLabel } (${ formatDuration ( end - start ) } )` }
176+ />
177+ ) ;
178+ } ) }
179+ </ div >
180+
181+ < div className = "mt-3 grid grid-cols-2 gap-2 text-xs text-gray-700 md:grid-cols-4 dark:text-gray-300" >
182+ < p >
183+ < span className = "font-semibold text-gray-900 dark:text-gray-100" > Driving:</ span > { " " }
184+ { formatHM ( sheet . totals . driving ?? 0 ) }
185+ </ p >
186+ < p >
187+ < span className = "font-semibold text-gray-900 dark:text-gray-100" > On-duty:</ span > { " " }
188+ { formatHM ( sheet . totals . on_duty ?? 0 ) }
189+ </ p >
190+ < p >
191+ < span className = "font-semibold text-gray-900 dark:text-gray-100" > Off-duty:</ span > { " " }
192+ { formatHM ( sheet . totals . off_duty ?? 0 ) }
193+ </ p >
194+ < p >
195+ < span className = "font-semibold text-gray-900 dark:text-gray-100" > Sleeper:</ span > { " " }
196+ { formatHM ( sheet . totals . sleeper_berth ?? 0 ) }
197+ </ p >
198+ </ div >
199+ </ article >
200+
201+ { nextSheet ? (
202+ < div className = "flex justify-center" >
203+ < div
204+ className = { `inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${ restPillClasses (
205+ restAcrossBoundary ,
206+ ) } `}
207+ >
208+ ↓ { formatHM ( restAcrossBoundary / 60 ) } continuous rest ·{ " " }
209+ { restart34Met
210+ ? "34h restart ✓"
211+ : `10h reset ${ restResetMet ? "✓" : "×" } ` }
212+ </ div >
213+ </ div >
214+ ) : null }
125215 </ div >
126- </ article >
127- ) ) }
216+ ) ;
217+ } ) }
128218 </ div >
129219 </ section >
130220 ) ;
131- }
221+ }
0 commit comments