@@ -16,7 +16,12 @@ function fmtRange(s,e) { return `${fmtTime(s)} – ${fmtTime(e)}`; }
function toDateIn ( iso ) { return iso ? iso . slice ( 0 , 10 ) : '' ; }
function toTimeIn ( iso ) { if ( ! iso ) return '' ; const d = new Date ( iso ) ; const h = String ( d . getHours ( ) ) . padStart ( 2 , '0' ) , m = d . getMinutes ( ) < 30 ? ' 00 ' : ' 30 ' ; return ` $ { h } : $ { m } ` ; }
function buildISO ( d , t ) { return d & & t ? ` $ { d } T $ { t } : 00 ` : ' ' ; }
function addHours ( iso , h ) { const d = new Date ( iso ) ; d.setMinutes ( d.getMinutes ( ) + h * 60 ) ; return d.toISOString ( ) .slice ( 0 , 19 ) ; }
function addHours ( iso , h ) {
const d = new Date ( iso ) ; d.setMinutes ( d.getMinutes ( ) + h * 60 ) ;
/ / Return local datetime string ( YYYY - MM - DDTHH : MM : SS ) — NOT toISOString ( ) which shifts to UTC
const pad = n = > String ( n ) . padStart ( 2 , '0' ) ;
return ` ${ d . getFullYear ( ) } - ${ pad ( d . getMonth ( ) + 1 ) } - ${ pad ( d . getDate ( ) ) } T ${ pad ( d . getHours ( ) ) } : ${ pad ( d . getMinutes ( ) ) } :00 ` ;
}
function sameDay ( a , b ) { return a . getFullYear ( ) === b . getFullYear ( ) && a . getMonth ( ) === b . getMonth ( ) && a . getDate ( ) === b . getDate ( ) ; }
function weekStart ( d ) { const r = new Date ( d ) ; r . setDate ( d . getDate ( ) - d . getDay ( ) ) ; r . setHours ( 0 , 0 , 0 , 0 ) ; return r ; }
function daysInMonth ( y , m ) { return new Date ( y , m + 1 , 0 ) . getDate ( ) ; }
@@ -215,8 +220,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
< Row label = "Event Type" >
< div style = { { display : 'flex' , gap : 8 , alignItems : 'center' , position : 'relative' } } ref = { typeRef } >
< select className = "input" value = { typeId } onChange = { e => setTypeId ( e . target . value ) } style = { { flex : 1 } } >
< option value = "" > Default < / option >
{ localTypes . filter ( t => ! t . is _default ) . map ( t => < option key = { t . id } value = { t . id } > { t . name } < / option > ) }
{ localTypes . map ( t => < option key = { t . id } value = { t . id } > { t . name } < / option > ) }
< / select >
{ isToolManager && < button className = "btn btn-secondary btn-sm" style = { { flexShrink : 0 } } onClick = { ( ) => setShowTypeForm ( v => ! v ) } > { showTypeForm ? 'Cancel' : '+ Type' } < / button > }
{ showTypeForm && < EventTypePopup userGroups = { userGroups } onSave = { et => { setLocalTypes ( p => [ ... p , et ] ) ; setShowTypeForm ( false ) ; } } onClose = { ( ) => setShowTypeForm ( false ) } / > }
@@ -453,29 +457,157 @@ function BulkImportPanel({ onImported, onCancel }) {
// ── Calendar Views ────────────────────────────────────────────────────────────
function ScheduleView ( { events , selectedDate , onSelect } ) {
const filtered = events . filter ( e => new Date ( e . end _at ) >= ( selectedDate || new Date ( 0 ) ) ) ;
if ( ! filtered . length ) return < div style = { { textAlign : 'center' , padding : '60px 20px' , color : 'var(--text-tertiary)' , fontSize : 14 } } > No upcoming events from selected date < / div > ;
const y = selectedDate . getFullYear ( ) , m = selectedDate . getMonth ( ) ;
const monthStart = new Date ( y , m , 1 ) , monthEnd = new Date ( y , m + 1 , 0 , 23 , 59 , 59 ) ;
const filtered = events . filter ( e => {
const s = new Date ( e . start _at ) ;
return s >= monthStart && s <= monthEnd ;
} ) ;
if ( ! filtered . length ) return < div style = { { textAlign : 'center' , padding : '60px 20px' , color : 'var(--text-tertiary)' , fontSize : 14 } } > No events in { MONTHS [ m ] } { y } < / div > ;
return < > { filtered . map ( e => { const s = new Date ( e . start _at ) ; const col = e . event _type ? . colour || '#9ca3af' ; return ( < div key = { e . id } onClick = { ( ) => onSelect ( e ) } style = { { display : 'flex' , alignItems : 'center' , gap : 20 , padding : '14px 20px' , borderBottom : '1px solid var(--border)' , cursor : 'pointer' } } onMouseEnter = { el => el . currentTarget . style . background = 'var(--background)' } onMouseLeave = { el => el . currentTarget . style . background = '' } > < div style = { { width : 44 , textAlign : 'center' , flexShrink : 0 } } > < div style = { { fontSize : 22 , fontWeight : 700 , lineHeight : 1 } } > { s . getDate ( ) } < / div > < div style = { { fontSize : 11 , color : 'var(--text-tertiary)' , textTransform : 'uppercase' } } > { SHORT _MONTHS [ s . getMonth ( ) ] } , { DAYS [ s . getDay ( ) ] } < / div > < / div > < div style = { { width : 100 , flexShrink : 0 , display : 'flex' , alignItems : 'center' , gap : 8 , fontSize : 13 , color : 'var(--text-secondary)' } } > < span style = { { width : 10 , height : 10 , borderRadius : '50%' , background : col , flexShrink : 0 } } / > { e . all _day ? 'All day' : fmtRange ( e . start _at , e . end _at ) } < / div > < div style = { { flex : 1 , minWidth : 0 } } > < div style = { { fontSize : 14 , fontWeight : 600 , display : 'flex' , alignItems : 'center' , gap : 8 } } > { e . event _type ? . name && < span style = { { fontSize : 11 , color : 'var(--text-tertiary)' , textTransform : 'uppercase' , letterSpacing : '0.5px' , fontWeight : 600 } } > { e . event _type . name } : < / span > } { e . title } { e . track _availability && ! e . my _response && < span style = { { width : 8 , height : 8 , borderRadius : '50%' , background : '#ef4444' , flexShrink : 0 } } title = "Awaiting your response" / > } < / div > { e . location && < div style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginTop : 2 } } > { e . location } < / div > } < / div > < / div > ) ; } ) } < / > ;
}
const HOUR _H = 56 ; // px per hour row
function eventTopOffset ( startDate ) {
const h = startDate . getHours ( ) , m = startDate . getMinutes ( ) ;
return ( h - 7 ) * HOUR _H + ( m / 60 ) * HOUR _H ;
}
function eventHeightPx ( startDate , endDate ) {
const diffMs = endDate - startDate ;
const diffHrs = diffMs / ( 1000 * 60 * 60 ) ;
return Math . max ( diffHrs * HOUR _H , HOUR _H * 0.4 ) ; // min 40% of one hour row
}
function DayView ( { events , selectedDate , onSelect } ) {
const hours = Array . from ( { length : 16 } , ( _ , i ) => i + 7 ) ;
const hours = Array . from ( { length : 16 } , ( _ , i ) => i + 7 ) ; // 7am– 10pm
const day = events . filter ( e => sameDay ( new Date ( e . start _at ) , selectedDate ) ) ;
return ( < div > < div style = { { display : 'flex' , borderBottom : '1px solid var(--border)' , padding : '8px 0 8px 60px' , fontSize : 13 , fontWeight : 600 , color : 'var(--primary)' } } > < div style = { { textAlign : 'center' } } > < div > { DAYS [ selectedDate . getDay ( ) ] } < / div > < div style = { { fontSize : 28 , fontWeight : 700 } } > { selectedDate . getDate ( ) } < / div > < / div > < / div > { hours . map ( h => ( < div key = { h } style = { { display : 'flex' , borderBottom : '1px solid var(--border)' , minHeight : 52 } } > < div style = { { width : 60 , flexShrink : 0 , fontSize : 11 , color : 'var(--text-tertiary)' , padding : '3px 10px 0' , textAlign : 'right' } } > { h > 12 ? ` ${ h - 12 } PM ` : h === 12 ? '12 PM' : ` ${ h } AM ` } < / div > < div style = { { flex : 1 , padding : '2px 4px' } } > { day . filter ( e => new Date ( e . start _at ) . getHours ( ) === h ) . map ( e => ( < div key = { e . id } onClick = { ( ) => onSelect ( e ) } style = { { margin : '2px 0' , padding : '5px 10px' , borderRadius : 5 , background : e . event _type ? . colour || '#6366f1' , color : 'white' , fontSize : 12 , cursor : 'pointer' , fontWeight : 600 } } > { e . title } · { fmtRange ( e . start _at , e . end _at ) } < / div > ) ) } < / div > < / div > ) ) } < / div > ) ;
return (
< div >
< div style = { { display : 'flex' , borderBottom : '1px solid var(--border)' , padding : '8px 0 8px 60px' , fontSize : 13 , fontWeight : 600 , color : 'var(--primary)' } } >
< div style = { { textAlign : 'center' } } > < div > { DAYS [ selectedDate . getDay ( ) ] } < / div > < div style = { { fontSize : 28 , fontWeight : 700 } } > { selectedDate . getDate ( ) } < / div > < / div >
< / div >
< div style = { { position : 'relative' } } >
{ /* Hour grid */ }
{ hours . map ( h => (
< div key = { h } style = { { display : 'flex' , borderBottom : '1px solid var(--border)' , height : HOUR _H } } >
< div style = { { width : 60 , flexShrink : 0 , fontSize : 11 , color : 'var(--text-tertiary)' , padding : '3px 10px 0' , textAlign : 'right' } } >
{ h > 12 ? ` ${ h - 12 } PM ` : h === 12 ? '12 PM' : ` ${ h } AM ` }
< / div >
< div style = { { flex : 1 } } / >
< / div >
) ) }
{ /* Event blocks — absolutely positioned */ }
{ day . map ( e => {
const s = new Date ( e . start _at ) , en = new Date ( e . end _at ) ;
const top = eventTopOffset ( s ) , height = eventHeightPx ( s , en ) ;
return (
< div key = { e . id } onClick = { ( ) => onSelect ( e ) } style = { {
position : 'absolute' , left : 64 , right : 8 ,
top , height ,
background : e . event _type ? . colour || '#6366f1' , color : 'white' ,
borderRadius : 5 , padding : '3px 8px' , cursor : 'pointer' ,
fontSize : 12 , fontWeight : 600 , overflow : 'hidden' ,
boxShadow : '0 1px 3px rgba(0,0,0,0.2)' ,
} } >
< div style = { { whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' } } > { e . title } < / div >
{ height > 32 && < div style = { { fontSize : 10 , opacity : 0.85 } } > { fmtRange ( e . start _at , e . end _at ) } < / div > }
< / div >
) ;
} ) }
< / div >
< / div >
) ;
}
function WeekView ( { events , selectedDate , onSelect } ) {
const ws = weekStart ( selectedDate ) , days = Array . from ( { length : 7 } , ( _ , i ) => { const d = new Date ( ws ) ; d . setDate ( d . getDate ( ) + i ) ; return d ; } ) ;
const hours = Array . from ( { length : 16 } , ( _ , i ) => i + 7 ) , today = new Date ( ) ;
return ( < div > < div style = { { display : 'grid' , gridTemplateColumns : '60px repeat(7,1fr)' , borderBottom : '1px solid var(--border)' } } > < div / > { days . map ( ( d , i ) => < div key = { i } style = { { textAlign : 'center' , padding : '6px 4px' , fontSize : 12 , fontWeight : 600 , color : sameDay ( d , today ) ? 'var(--primary)' : 'var(--text-secondary)' } } > { DAYS [ d . getDay ( ) ] } { d . getDate ( ) } < / div > ) } < / div > { hours . map ( h => ( < div key = { h } style = { { display : 'grid' , gridTemplateColumns : '60px repeat(7,1fr)' , borderBottom : '1px solid var(--border)' , minHeight : 46 } } > < div style = { { fontSize : 11 , color : 'var(--text-tertiary)' , padding : '3px 10px 0' , textAlign : 'right' } } > { h > 12 ? ` ${ h - 12 } PM ` : h === 12 ? '12 PM' : ` ${ h } AM ` } < / div > { days . map ( ( d , i ) => ( < div key = { i } style = { { borderLeft : '1px solid var(--border)' , padding : '1px 2px' } } > { events . filter ( e => sameDay ( new Date ( e . start _at ) , d ) && new Date ( e . start _at ) . getHours ( ) === h ) . map ( e => ( < div key = { e . id } onClick = { ( ) => onSelect ( e ) } style = { { background : e . event _type ? . colour || '#6366f1' , color : 'white' , borderRadius : 3 , padding : '2px 5px' , fontSize : 11 , cursor : 'pointer' , marginBottom : 1 , fontWeight : 600 , overflow : 'hidden' , whiteSpace : 'nowrap' , textOverflow : 'ellipsis' } } > { e . title } < / div > ) ) } < / div > ) ) } < / div > ) ) } < / div > ) ;
return (
< div >
{ /* Day headers */ }
< div style = { { display : 'grid' , gridTemplateColumns : '60px repeat(7,1fr)' , borderBottom : '1px solid var(--border)' , position : 'sticky' , top : 0 , background : 'var(--surface)' , zIndex : 2 } } >
< div / >
{ days . map ( ( d , i ) => < div key = { i } style = { { textAlign : 'center' , padding : '6px 4px' , fontSize : 12 , fontWeight : 600 , color : sameDay ( d , today ) ? 'var(--primary)' : 'var(--text-secondary)' } } > { DAYS [ d . getDay ( ) ] } { d . getDate ( ) } < / div > ) }
< / div >
{ /* Time grid with event columns */ }
< div style = { { display : 'grid' , gridTemplateColumns : '60px repeat(7,1fr)' , position : 'relative' } } >
{ /* Time labels column */ }
< div >
{ hours . map ( h => (
< div key = { h } style = { { height : HOUR _H , borderBottom : '1px solid var(--border)' , fontSize : 11 , color : 'var(--text-tertiary)' , padding : '3px 10px 0' , textAlign : 'right' } } >
{ h > 12 ? ` ${ h - 12 } PM ` : h === 12 ? '12 PM' : ` ${ h } AM ` }
< / div >
) ) }
< / div >
{ /* Day columns */ }
{ days . map ( ( d , di ) => {
const dayEvs = events . filter ( e => sameDay ( new Date ( e . start _at ) , d ) ) ;
return (
< div key = { di } style = { { position : 'relative' , borderLeft : '1px solid var(--border)' } } >
{ hours . map ( h => < div key = { h } style = { { height : HOUR _H , borderBottom : '1px solid var(--border)' } } / > ) }
{ dayEvs . map ( e => {
const s = new Date ( e . start _at ) , en = new Date ( e . end _at ) ;
const top = eventTopOffset ( s ) , height = eventHeightPx ( s , en ) ;
return (
< div key = { e . id } onClick = { ( ) => onSelect ( e ) } style = { {
position : 'absolute' , top , left : 2 , right : 2 , height ,
background : e . event _type ? . colour || '#6366f1' , color : 'white' ,
borderRadius : 3 , padding : '2px 4px' , cursor : 'pointer' ,
fontSize : 11 , fontWeight : 600 , overflow : 'hidden' ,
boxShadow : '0 1px 2px rgba(0,0,0,0.2)' ,
} } >
< div style = { { whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' } } > { e . title } < / div >
{ height > 26 && < div style = { { fontSize : 9 , opacity : 0.85 } } > { fmtTime ( e . start _at ) } - { fmtTime ( e . end _at ) } < / div > }
< / div >
) ;
} ) }
< / div >
) ;
} ) }
< / div >
< / div >
) ;
}
const MONTH _CELL _H = 90 ; // fixed cell height in px
function MonthView ( { events , selectedDate , onSelect , onSelectDay } ) {
const y = selectedDate . getFullYear ( ) , m = selectedDate . getMonth ( ) , first = new Date ( y , m , 1 ) . getDay ( ) , total = daysInMonth ( y , m ) , today = new Date ( ) ;
const cells = [ ] ; for ( let i = 0 ; i < first ; i + + ) cells.push ( null ) ; for ( let d = 1 ; d < = total ; d + + ) cells.push ( d ) ;
while ( cells.length % 7 ! = = 0 ) cells.push ( null ) ;
const weeks = [ ] ; for ( let i = 0 ; i < cells.length ; i + = 7 ) weeks.push ( cells.slice ( i , i + 7 ) ) ;
return ( < div > < div style = { { display : 'grid' , gridTemplateColumns : 'repeat(7,1fr)' , borderBottom : '1px solid var(--border)' } } > { DAYS . map ( d => < div key = { d } style = { { textAlign : 'center' , padding : '8px' , fontSize : 12 , fontWeight : 600 , color : 'var(--text-tertiary)' } } > { d } < / div > ) } < / div > { weeks . map ( ( week , wi ) => ( < div key = { wi } style = { { display : 'grid' , gridTemplateColumns : 'repeat(7,1fr)' } } > { week . map ( ( d , di ) => { if ( ! d ) return < div key = { di } style = { { borderRight : '1px solid var(--border)' , borderBottom : '1px solid var(--border)' , minHeight : 90 , background : 'var(--surface-variant)' } } / > ; const date = new Date ( y , m , d ) , dayEvs = events . filter ( e => sameDay ( new Date ( e . start _at ) , date ) ) , isToday = sameDay ( date , today ) ; return ( < div key = { di } onClick = { ( ) => onSelectDay ( date ) } style = { { borderRight : '1px solid var(--border)' , borderBottom : '1px solid var(--border)' , padding : '4px' , minHeight : 90 , cursor : 'pointer' } } onMouseEnter = { el => el . currentTarget . style . background = 'var(--background)' } onMouseLeave = { el => el . currentTarget . style . background = '' } > < div style = { { width : 26 , height : 26 , borderRadius : '50%' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , marginBottom : 3 , fontSize : 13 , fontWeight : isToday ? 700 : 400 , background : isToday ? 'var(--primary)' : 'transparent' , color : isToday ? 'white' : 'var(--text-primary)' } } > { d } < / div > { dayEvs . slice ( 0 , 3 ) . map ( e => ( < div key = { e . id } onClick = { ev => { ev . stopPropagation ( ) ; onSelect ( e ) ; } } style = { { background : e . event _type ? . colour || '#6366f1' , color : 'white' , borderRadius : 3 , padding : '1px 5px' , fontSize : 11 , marginBottom : 2 , cursor : 'pointer' , whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' } } > { ! e . all _day && < span style = { { marginRight : 3 } } > { fmtTime ( e . start _at ) } < / span > } { e . title } < / div > ) ) } { dayEvs . length > 3 && < div style = { { fontSize : 10 , color : 'var(--text-tertiary)' } } > + { dayEvs . length - 3 } more < / div > } < / div > ) ; } ) } < / div > ) ) } < / div > ) ;
return (
< div >
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(7,1fr)' , borderBottom : '1px solid var(--border)' } } >
{ DAYS . map ( d => < div key = { d } style = { { textAlign : 'center' , padding : '8px' , fontSize : 12 , fontWeight : 600 , color : 'var(--text-tertiary)' } } > { d } < / div > ) }
< / div >
{ weeks . map ( ( week , wi ) => (
< div key = { wi } style = { { display : 'grid' , gridTemplateColumns : 'repeat(7,1fr)' } } >
{ week . map ( ( d , di ) => {
if ( ! d ) return < div key = { di } style = { { borderRight : '1px solid var(--border)' , borderBottom : '1px solid var(--border)' , height : MONTH _CELL _H , background : 'var(--surface-variant)' } } / > ;
const date = new Date ( y , m , d ) , dayEvs = events . filter ( e => sameDay ( new Date ( e . start _at ) , date ) ) , isToday = sameDay ( date , today ) ;
return (
< div key = { di } onClick = { ( ) => onSelectDay ( date ) } style = { { borderRight : '1px solid var(--border)' , borderBottom : '1px solid var(--border)' , height : MONTH _CELL _H , padding : '3px' , cursor : 'pointer' , overflow : 'hidden' , display : 'flex' , flexDirection : 'column' } }
onMouseEnter = { el => el . currentTarget . style . background = 'var(--background)' } onMouseLeave = { el => el . currentTarget . style . background = '' } >
< div style = { { width : 24 , height : 24 , borderRadius : '50%' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , marginBottom : 2 , fontSize : 12 , fontWeight : isToday ? 700 : 400 , background : isToday ? 'var(--primary)' : 'transparent' , color : isToday ? 'white' : 'var(--text-primary)' , flexShrink : 0 } } > { d } < / div >
{ dayEvs . slice ( 0 , 2 ) . map ( e => (
< div key = { e . id } onClick = { ev => { ev . stopPropagation ( ) ; onSelect ( e ) ; } } style = { {
background : e . event _type ? . colour || '#6366f1' , color : 'white' ,
borderRadius : 3 , padding : '1px 4px' , fontSize : 11 , marginBottom : 1 , cursor : 'pointer' ,
whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' , flexShrink : 0 ,
} } >
{ ! e . all _day && < span style = { { marginRight : 3 , opacity : 0.85 } } > { fmtTime ( e . start _at ) } < / span > } { e . title }
< / div >
) ) }
{ dayEvs . length > 2 && < div style = { { fontSize : 10 , color : 'var(--text-tertiary)' , flexShrink : 0 } } > + { dayEvs . length - 2 } more < / div > }
< / div >
) ;
} ) }
< / div >
) ) }
< / div >
) ;
}
// ── Main Schedule Page ────────────────────────────────────────────────────────
@@ -525,7 +657,7 @@ export default function SchedulePage({ isToolManager, isMobile }) {
const navLabel = ( ) => {
if ( view === 'day' ) return ` ${ DAYS [ selDate . getDay ( ) ] } ${ selDate . getDate ( ) } ${ MONTHS [ selDate . getMonth ( ) ] } ${ selDate . getFullYear ( ) } ` ;
if ( view === 'week' ) { const ws = weekStart ( selDate ) , we = new Date ( ws ) ; we . setDate ( we . getDate ( ) + 6 ) ; return ` ${ SHORT _MONTHS [ ws . getMonth ( ) ] } ${ ws . getDate ( ) } – ${ SHORT _MONTHS [ we . getMonth ( ) ] } ${ we . getDate ( ) } ${ we . getFullYear ( ) } ` ; }
return ` ${ MONTHS [ selDate . getMonth ( ) ] } ${ selDate . getFullYear ( ) } ` ;
return ` ${ MONTHS [ selDate . getMonth ( ) ] } ${ selDate . getFullYear ( ) } ` ; // schedule + month
} ;
const openDetail = async e => {
@@ -602,7 +734,7 @@ export default function SchedulePage({ isToolManager, isMobile }) {
< button className = "btn-icon" onClick = { ( ) => navDate ( - 1 ) } style = { { fontSize : 16 , padding : '2px 8px' } } > ‹ < / button >
< button className = "btn-icon" onClick = { ( ) => navDate ( 1 ) } style = { { fontSize : 16 , padding : '2px 8px' } } > › < / button >
< / div >
{ view !== 'schedule' && <span style = { { fontSize : 13 , fontWeight : 600 , color : 'var(--text-primary)' , whiteSpace : 'nowrap' } } > { navLabel ( ) } < / span > }
< span style = { { fontSize : 13 , fontWeight : 600 , color : 'var(--text-primary)' , whiteSpace : 'nowrap' } } > { navLabel ( ) } < / span >
< div style = { { marginLeft : 'auto' } } / >
< / >
) }