@@ -86,11 +86,14 @@ function TagPill({ tag, availableTags }: { tag: string; availableTags: string[]
) ;
}
function detectConflicts ( events : TripEvent [ ] ) : Set < string > {
function detectConflicts ( events : TripEvent [ ] , overlapTags : string [ ] = [ ] ) : Set < string > {
const timed = events . filter ( ( e ) = > e . start_time ) ;
const conflicts = new Set < string > ( ) ;
for ( let i = 0 ; i < timed . length ; i ++ ) {
for ( let j = i + 1 ; j < timed . length ; j ++ ) {
const aExempt = timed [ i ] . tags ? . some ( ( t ) = > overlapTags . includes ( t ) ) ;
const bExempt = timed [ j ] . tags ? . some ( ( t ) = > overlapTags . includes ( t ) ) ;
if ( aExempt || bExempt ) continue ;
const aS = toMin ( timed [ i ] . start_time ! ) ;
const aE = timed [ i ] . end_time ? toMin ( timed [ i ] . end_time ! ) : aS + 60 ;
const bS = toMin ( timed [ j ] . start_time ! ) ;
@@ -365,6 +368,7 @@ function DayTimeline({
onEdit ,
driveSegments ,
availableTags ,
overlapTags ,
} : {
events : TripEvent [ ] ;
isAdmin : boolean ;
@@ -372,12 +376,16 @@ function DayTimeline({
onEdit : ( event : TripEvent ) = > void ;
driveSegments : { fromId : string ; toId : string ; text : string } [ ] ;
availableTags : string [ ] ;
overlapTags : string [ ] ;
} ) {
const timed = [ . . . events . filter ( ( e ) = > e . start_time ) ] . sort (
( a , b ) = > toMin ( a . start_time ! ) - toMin ( b . start_time ! )
) ;
const untimed = events . filter ( ( e ) = > ! e . start_time ) ;
const conflicts = detectConflicts ( events ) ;
const isNote = ( e : TripEvent ) = > ! ! e . tags ? . some ( ( t ) = > overlapTags . includes ( t ) ) ;
const noteEvents = timed . filter ( ( e ) = > isNote ( e ) ) ;
const regularEvents = timed . filter ( ( e ) = > ! isNote ( e ) ) ;
const conflicts = detectConflicts ( events , overlapTags ) ;
if ( timed . length === 0 && untimed . length === 0 ) {
return (
@@ -423,8 +431,45 @@ function DayTimeline({
< / div >
) ) }
{ /* Event block s */ }
{ timed . map ( ( e ) = > {
{ /* Note events — overlap-allowed, rendered behind as subtle band s */ }
{ noteEvents . map ( ( e ) = > {
const startMin = toMin ( e . start_time ! ) ;
const endMin = e . end_time ? toMin ( e . end_time ) : startMin ;
const top = ( startMin - rangeStart ) * PX_PER_MIN ;
const height = Math . max ( 1 , ( endMin - startMin ) * PX_PER_MIN ) ;
return (
< div key = { e . event_id } className = "group" >
{ /* Shaded band (only if duration given) */ }
{ e . end_time && (
< div
style = { { top , height , left : 60 , right : 0 } }
className = "absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
/ >
) }
{ /* Dashed marker line + label */ }
< div
style = { { top , left : 60 , right : 0 } }
className = "absolute z-10 flex items-center gap-2"
>
< div className = "flex-1 border-t border-dashed border-gray-600/40" / >
< span className = "text-gray-500 text-[10px] font-mono shrink-0 pr-1" >
{ fmtTime ( e . start_time ) }
< / span >
< span className = "text-gray-500 text-[10px] truncate max-w-[120px] shrink-0" > { e . title } < / span >
{ isAdmin && (
< div className = "flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" >
< button onClick = { ( ) = > onEdit ( e ) } className = "text-gray-600 hover:text-indigo-400 text-[10px]" > Edit < / button >
< button onClick = { ( ) = > onDelete ( e . event_id ) } className = "text-gray-600 hover:text-red-400 text-xs leading-none" > × < / button >
< / div >
) }
< / div >
< / div >
) ;
} ) }
{ /* Regular event blocks */ }
{ regularEvents . map ( ( e ) = > {
const startMin = toMin ( e . start_time ! ) ;
const endMin = e . end_time ? toMin ( e . end_time ) : startMin + 60 ;
const top = ( startMin - rangeStart ) * PX_PER_MIN ;
@@ -436,7 +481,7 @@ function DayTimeline({
< div key = { e . event_id } >
< div
style = { { top , height , left : 60 , right : 0 } }
className = { ` absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors ${
className = { ` absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict
? "bg-red-950/70 border border-red-700/70"
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
@@ -495,11 +540,10 @@ function DayTimeline({
< / div >
< / div >
< / div >
{ /* Drive time badge below this event, if present */ }
{ drive && (
< div
style = { { top : top + height + 2 , left : 60 } }
className = "absolute text-xs text-gray-600 font-mono flex items-center gap-1"
className = "absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20 "
>
< span className = "text-gray-700" > ↓ < / span > { drive } drive
< / div >
@@ -965,17 +1009,27 @@ export default function TripDetailPage() {
if ( ! trip || ! tagInput . trim ( ) ) return ;
const tag = tagInput . trim ( ) ;
if ( trip . available_tags ? . includes ( tag ) ) { setTagInput ( "" ) ; return ; }
const updated = [ . . . ( trip . available_tags ? ? [ ] ) , tag ] ;
await c2api . updateTripTags ( trip . trip_id , updated ) ;
setTrip ( ( prev ) = > prev ? { . . . prev , available_tags : updated } : prev ) ;
const available = [ . . . ( trip . available_tags ? ? [ ] ) , tag ] ;
const overlap = trip . overlap_tags ? ? [ ] ;
await c2api . updateTripTags ( trip . trip_id , available , overlap ) ;
setTrip ( ( prev ) = > prev ? { . . . prev , available_tags : available } : prev ) ;
setTagInput ( "" ) ;
}
async function handleRemoveTag ( tag : string ) {
if ( ! trip ) return ;
const updated = ( trip . available_tags ? ? [ ] ) . filter ( ( t ) = > t !== tag ) ;
await c2api . updateTripTags ( trip . trip_id , updated ) ;
setTrip ( ( prev ) = > prev ? { . . . prev , available_tags : updated } : prev ) ;
const available = ( trip . available_tags ? ? [ ] ) . filter ( ( t ) = > t !== tag ) ;
const overlap = ( trip . overlap_tags ? ? [ ] ) . filter ( ( t ) = > t !== tag ) ;
await c2api . updateTripTags ( trip . trip_id , available , overlap ) ;
setTrip ( ( prev ) = > prev ? { . . . prev , available_tags : available , overlap_tags : overlap } : prev ) ;
}
async function handleToggleOverlap ( tag : string ) {
if ( ! trip ) return ;
const current = trip . overlap_tags ? ? [ ] ;
const overlap = current . includes ( tag ) ? current . filter ( ( t ) = > t !== tag ) : [ . . . current , tag ] ;
await c2api . updateTripTags ( trip . trip_id , trip . available_tags ? ? [ ] , overlap ) ;
setTrip ( ( prev ) = > prev ? { . . . prev , overlap_tags : overlap } : prev ) ;
}
if ( loading ) return < p className = "text-gray-500 text-sm font-mono" > Loading … < / p > ;
@@ -985,7 +1039,7 @@ export default function TripDetailPage() {
const attendees = Object . values ( trip . attendees ? ? { } ) ;
const dayEvents = trip . events . filter ( ( e ) = > e . date === selectedDay ) ;
const hasConflict = ( day : string ) = >
detectConflicts ( trip . events . filter ( ( e ) = > e . date === day ) ) . size > 0 ;
detectConflicts ( trip . events . filter ( ( e ) = > e . date === day ) , trip . overlap_tags ? ? [ ] ) . size > 0 ;
return (
< div className = "space-y-6" >
@@ -1043,14 +1097,26 @@ export default function TripDetailPage() {
{ /* Tag manager */ }
{ ( isAdmin || ( trip . available_tags ? ? [ ] ) . length > 0 ) && (
< div className = "flex flex-wrap items-center gap-2" >
{ ( trip . available_tags ? ? [ ] ) . map ( ( tag ) = > (
< span key = { tag } className = { ` inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${ tagColor ( tag , trip . available_tags ? ? [ ] ) } ` } >
{ tag }
{ isAdmin && (
< button onClick = { ( ) = > handleRemoveTag ( tag ) } className = "hover:text-white transition-colors leading-none" > × < / button >
) }
< / span >
) ) }
{ ( trip . available_tags ? ? [ ] ) . map ( ( tag ) = > {
const isOverlap = ( trip . overlap_tags ? ? [ ] ) . includes ( tag ) ;
return (
< span key = { tag } className = { ` inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${ tagColor ( tag , trip . available_tags ? ? [ ] ) } ` } >
{ tag }
{ isAdmin && (
< >
< button
onClick = { ( ) = > handleToggleOverlap ( tag ) }
title = { isOverlap ? "Allows overlap (click to disable)" : "Click to allow overlap" }
className = { ` leading-none transition-colors ${ isOverlap ? "opacity-100" : "opacity-30 hover:opacity-70" } ` }
>
≋
< / button >
< button onClick = { ( ) = > handleRemoveTag ( tag ) } className = "hover:text-white transition-colors leading-none opacity-60 hover:opacity-100" > × < / button >
< / >
) }
< / span >
) ;
} ) }
{ isAdmin && (
< div className = "flex items-center gap-1" >
< input
@@ -1114,6 +1180,7 @@ export default function TripDetailPage() {
onEdit = { setEditEvent }
driveSegments = { driveTimes [ selectedDay ] ? ? [ ] }
availableTags = { trip . available_tags ? ? [ ] }
overlapTags = { trip . overlap_tags ? ? [ ] }
/ >
< / div >
< / div >