@@ -100,7 +100,7 @@ function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
}
// ── Mobile Filter Bar (Schedule view: keyword+type filters with month nav; Day view: calendar accordion) ──
function MobileScheduleFilter ( { selected , onMonthChange , view , eventTypes , filterKeyword , onFilterKeyword , filterTypeId , onFilterTypeId , eventDates = new Set ( ) , onInputFocus , onInputBlur } ) {
function MobileScheduleFilter ( { selected , onMonthChange , view , eventTypes , filterKeyword , onFilterKeyword , filterTypeId , onFilterTypeId , filterAvailability = false , onFilterAvailability , eventDates = new Set ( ) , onInputFocus , onInputBlur } ) {
// Day view: keep accordion calendar
const [ open , setOpen ] = useState ( false ) ;
const y = selected . getFullYear ( ) , m = selected . getMonth ( ) ;
@@ -141,42 +141,45 @@ function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filte
) ;
}
// Schedule view: filter bar with month nav + keyword + event type
const hasFilters = filterKeyword || filterTypeId ;
// Schedule view: accordion "Filter Events" + month nav
const hasFilters = filterKeyword || filterTypeId || filterAvailability ;
return (
< div style = { { background : 'var(--surface)' , borderBottom : '1px solid var(--border)' } } >
{ /* Month nav row */ }
< div style = { { display : 'flex' , alignItems : 'center' , padding : '8px 16px 0 ' , gap : 8 } } >
< button onClick = { ( ) => onMonthChange ( - 1 ) } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : 'var(--text-secondary)' , fontSize : 18 , padding : '2 px 6 px' , lineHeight : 1 } } > ‹ < / button >
{ /* Month nav row — always visible */ }
< div style = { { display : 'flex' , alignItems : 'center' , padding : '0 8px' , gap : 4 } } >
< button onClick = { ( ) => onMonthChange ( - 1 ) } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : 'var(--text-secondary)' , fontSize : 18 , padding : '6 px 8 px' , lineHeight : 1 } } > ‹ < / button >
< span style = { { flex : 1 , textAlign : 'center' , fontSize : 14 , fontWeight : 600 } } > { MONTHS [ m ] } { y } < / span >
< button onClick = { ( ) => onMonthChange ( 1 ) } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : 'var(--text-secondary)' , fontSize : 18 , padding : '2 px 6 px' , lineHeight : 1 } } > › < / button >
< button onClick = { ( ) => onMonthChange ( 1 ) } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : 'var(--text-secondary)' , fontSize : 18 , padding : '6 px 8 px' , lineHeight : 1 } } > › < / button >
{ /* Filter accordion toggle */ }
< button onClick = { ( ) => setOpen ( v => ! v ) } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , gap : 4 , padding : '6px 8px' , color : hasFilters ? 'var(--primary)' : 'var(--text-secondary)' , fontSize : 12 , fontWeight : 600 } } >
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < polygon points = "22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" / > < / svg >
{ hasFilters ? 'Filtered' : 'Filter' }
< span style = { { fontSize : 9 , transform : open ? 'rotate(180deg)' : 'none' , display : 'inline-block' , transition : 'transform 0.15s' } } > ▼ < / span >
< / button >
< / div >
{ /* Filter inputs */ }
< div style = { { padding : '8px 12px 10px' , display : 'flex' , gap : 8 , alignItems : 'center' } } >
< div style = { { flex : 1 , position : 'relative '} } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "var(--text-tertiary)" strokeWidth = "2" style = { { position : 'absolute' , left : 9 , top : '50%' , transform : 'translateY(-50%)' , pointerEvents : 'none' } } > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg >
< input
value = { filterKeyword }
onChange = { e => onFilterKeyword ( e . target . value ) }
onFocus = { onInputFocus }
onBlur = { onInputBlur }
placeholder = "Search events…"
autoComplete = "new-password" autoCorrect = "off" autoCapitalize = "off" spellCheck = { false }
style = { { width : '100%' , padding : '7px 8px 7px 28px' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , background : 'var(--background)' , color : 'var(--text-primary)' , fontSize : 13 , boxSizing : 'border-box' } }
/ >
{ /* Collapsible filter panel */ }
{ open && (
< div style = { { padding : '8px 12px 12px' , borderTop : '1px solid var(--border) '} } >
< div style = { { position : 'relative' , marginBottom : 8 } } >
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "var(--text-tertiary)" strokeWidth = "2" style = { { position : 'absolute' , left : 9 , top : '50%' , transform : 'translateY(-50%)' , pointerEvents : 'none' } } > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg >
< input value = { filterKeyword } onChange = { e => onFilterKeyword ( e . target . value ) } onFocus = { onInputFocus } onBlur = { onInputBlur }
placeholder = "Search events…" autoComplete = "new-password" autoCorrect = "off" autoCapitalize = "off" spellCheck = { false }
style = { { width : '100%' , padding : '7px 8px 7px 28px' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , background : 'var(--background)' , color : 'var(--text-primary)' , fontSize : 13 , boxSizing : 'border-box' } } / >
< / div >
< select
valu e= { filterTypeId }
onChange = { e = >onFilterTypeId ( e . target . value ) }
style = { { padding : '7px 8px' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , background : 'var(--background)' , color : 'var(--text-primary)' , fontSize : 13 , flexShrink : 0 , maxWidth : 130 } }
>
< option value = "" > All types < / option >
< select value = { filterTypeId } onChange = { e => onFilterTypeId ( e . target . value ) }
styl e = { { width : '100%' , padding : '7px 8px' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , background : 'var(--background)' , color : 'var(--text-primary)' , fontSize : 13 , marginBottom : 8 } } >
< option value = "" > All event types < / option >
{ eventTypes . map ( t => < option key = { t . id } value = { t . id } > { t . name } < / option > ) }
< / select >
< label style = { { display : 'flex' , alignItems : 'center' , gap : 8 , fontSize : 13 , cursor : 'pointer' , marginBottom : hasFilters ? 8 : 0 } } >
< input type = "checkbox" checked = { filterAvailability } onChange = { e => onFilterAvailability ( e . target . checked ) } style = { { accentColor : 'var(--primary)' , width : 14 , height : 14 } } / >
Requires Availability
< / label >
{ hasFilters && (
< button onClick = { ( ) => { onFilterKeyword ( '' ) ; onFilterTypeId ( '' ) ; } } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : 'var(--text-tertiary)' , fontSize : 18 , padding : '2px 4px' , lineHeight : 1 , flexShrink : 0 } } > ✕ < / button >
< button onClick = { ( ) => { onFilterKeyword ( '' ) ; onFilterTypeId ( '' ) ; onFilterAvailability ( false ) ; } } style = { { fontSize : 12 , color : 'var(--error)' , background : 'none' , border : 'none' , cursor : 'pointer' , padding : 0 } } > ✕ Clear all filters < / button >
) }
< / div >
) }
< / div >
) ;
}
@@ -392,7 +395,16 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
if ( ! allDay && ( ! sd || ! st || ! ed || ! et ) ) return toast ( 'Start and end required' , 'error' ) ;
if ( groupsRequired && grps . size === 0 ) return toast ( 'Select at least one group for availability tracking' , 'error' ) ;
setSaving ( true ) ;
try { const body = { title : title . trim ( ) , eventTypeId : typeId || null , startAt : allDay ? ` ${ sd } T00:00:00 ` : buildISO ( sd , st ) , endAt : allDay ? ` ${ ed } T23:59:59 ` : buildISO ( ed , et ) , allDay , location : loc , description : desc , isPublic : pub , trackAvailability : track , userGroupIds : [ ... grps ] , recurrenceRule : recRule || null } ; const r = event ? await api . updateEvent ( event . id , body ) : await api . createEvent ( body ) ; onSave ( r . event ) ; } catch ( e ) { toast ( e . message , 'error' ) ; } finally { setSaving ( false ) ; }
try {
const body = { title : title . trim ( ) , eventTypeId : typeId || null , startAt : allDay ? ` ${ sd } T00:00:00 ` : buildISO ( sd , st ) , endAt : allDay ? ` ${ ed } T23:59:59 ` : buildISO ( ed , et ) , allDay , location : loc , description : desc , isPublic : pub , trackAvailability : track , userGroupIds : [ ... grps ] , recurrenceRule : recRule || null } ;
let scope = 'this' ;
if ( event && event . recurrence _rule ? . freq ) {
const choice = window . confirm ( 'This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only' ) ;
scope = choice ? 'future' : 'this' ;
}
const r = event ? await api . updateEvent ( event . id , { ... body , recurringScope : scope } ) : await api . createEvent ( body ) ;
onSave ( r . event ) ;
} catch ( e ) { toast ( e . message , 'error' ) ; } finally { setSaving ( false ) ; }
} ;
return (
@@ -710,11 +722,11 @@ function parseKeywords(raw) {
return terms ;
}
function ScheduleView ( { events , selectedDate , onSelect , filterKeyword = '' , filterTypeId = '' , isMobile = false } ) {
function ScheduleView ( { events , selectedDate , onSelect , filterKeyword = '' , filterTypeId = '' , filterAvailability = false , isMobile = false } ) {
const y = selectedDate . getFullYear ( ) , m = selectedDate . getMonth ( ) ;
const today = new Date ( ) ; today . setHours ( 0 , 0 , 0 , 0 ) ;
const terms = parseKeywords ( filterKeyword ) ;
const hasFilters = terms . length > 0 || ! ! filterTypeId ;
const hasFilters = terms . length > 0 || ! ! filterTypeId || filterAvailability ;
const now = new Date ( ) ; // exact now for end-time comparison
const isCurrentMonth = y === today . getFullYear ( ) && m === today . getMonth ( ) ;
// No filters: show from today (if current month) or start of month (future months) to end of month.
@@ -726,6 +738,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
const s = new Date ( e . start _at ) ;
if ( s < from | | s > to ) return false ;
if ( filterTypeId && String ( e . event _type _id ) !== String ( filterTypeId ) ) return false ;
if ( filterAvailability && ! e . track _availability ) return false ;
if ( terms . length > 0 ) {
const haystack = [ e . title || '' , e . location || '' , e . description || '' ] . join ( ' ' ) . toLowerCase ( ) ;
if ( ! terms . some ( t => haystack . includes ( t ) ) ) return false ;
@@ -975,18 +988,20 @@ function MonthView({ events, selectedDate, onSelect, onSelectDay }) {
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 ) ) ;
const nWeeks = weeks.length ;
return (
< div >
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(7,1fr)' , borderBottom : '1px solid var(--border)' } } >
< div style = { { flex : 1 , display : 'flex' , flexDirection : 'column' , overflow : 'hidden' } } >
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(7,1fr)' , borderBottom : '1px solid var(--border)' , flexShrink : 0 }} >
{ DAYS . map ( d => < div key = { d } style = { { textAlign : 'center' , padding : '8px' , fontSize : 12 , fontWeight : 600 , color : 'var(--text-tertiary)' } } > { d } < / div > ) }
< / div >
< div style = { { flex : 1 , display : 'grid' , gridTemplateRows : ` repeat( ${ nWeeks } ,1fr) ` , overflow : 'hidden' } } >
{ 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)' , h eight: MONTH _CELL _H , background : 'var(--surface-variant)' } } / > ;
if ( ! d ) return < div key = { di } style = { { borderRight : '1px solid var(--border)' , borderBottom : '1px solid var(--border)' , minH eight: 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)' , h eight: MONTH _CELL _H , padding : '3px' , cursor : 'pointer' , overflow : 'hidden' , display : 'flex' , flexDirection : 'column' } }
< div key = { di } onClick = { ( ) => onSelectDay ( date ) } style = { { borderRight : '1px solid var(--border)' , borderBottom : '1px solid var(--border)' , minH eight: 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 => (
@@ -1005,6 +1020,7 @@ function MonthView({ events, selectedDate, onSelect, onSelectDay }) {
< / div >
) ) }
< / div >
< / div >
) ;
}
@@ -1024,6 +1040,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const [ editingEvent , setEditingEvent ] = useState ( null ) ;
const [ filterKeyword , setFilterKeyword ] = useState ( '' ) ;
const [ filterTypeId , setFilterTypeId ] = useState ( '' ) ;
const [ filterAvailability , setFilterAvailability ] = useState ( false ) ;
const [ inputFocused , setInputFocused ] = useState ( false ) ; // hides footer when keyboard open on mobile
const [ detailEvent , setDetailEvent ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
@@ -1141,10 +1158,14 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
< option value = "" > All event types < / option >
{ eventTypes . map ( t => < option key = { t . id } value = { t . id } > { t . name } < / option > ) }
< / select >
{ ( filterKeyword || filterTypeId ) && (
< label style = {{ display : 'flex' , alignItems : 'center' , gap : 8 , fontSize : 13 , cursor : 'pointer' , marginTop : 6 } } >
< input type = "checkbox" checked = { filterAvailability } onChange = { e => setFilterAvailability ( e . target . checked ) } style = { { accentColor : 'var(--primary)' , width : 14 , height : 14 } } / >
Requires Availability
< / label >
{ ( filterKeyword || filterTypeId || filterAvailability ) && (
< button
className = "btn btn-secondary btn-sm"
onClick = { ( ) => { setFilterKeyword ( '' ) ; setFilterTypeId ( '' ) ; } }
onClick = { ( ) => { setFilterKeyword ( '' ) ; setFilterTypeId ( '' ) ; setFilterAvailability ( false ) ; } }
style = { { marginTop : 8 , width : '100%' } }
> Clear filters < / button >
) }
@@ -1181,7 +1202,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
{ allowedViews . map ( v => {
const labels = { schedule : 'Schedule' , day : 'Day' , week : 'Week' , month : 'Month' } ;
return (
< button key = { v } onClick = { ( ) => { setView ( v ) ; setPanel ( 'calendar' ) ; setSelDate ( new Date ( ) ) ; setFilterKeyword ( '' ) ; setFilterTypeId ( '' ) ; } } style = { { padding : '4px 10px' , borderRadius : 5 , border : 'none' , cursor : 'pointer' , fontSize : 12 , fontWeight : 600 , background : view === v ? 'var(--surface)' : 'transparent' , color : view === v ? 'var(--text-primary)' : 'var(--text-tertiary)' , boxShadow : view === v ? '0 1px 3px rgba(0,0,0,0.1)' : 'none' , transition : 'all 0.15s' , whiteSpace : 'nowrap' } } >
< button key = { v } onClick = { ( ) => { setView ( v ) ; setPanel ( 'calendar' ) ; setSelDate ( new Date ( ) ) ; setFilterKeyword ( '' ) ; setFilterTypeId ( '' ) ; setFilterAvailability ( false ) ; } } style = { { padding : '4px 10px' , borderRadius : 5 , border : 'none' , cursor : 'pointer' , fontSize : 12 , fontWeight : 600 , background : view === v ? 'var(--surface)' : 'transparent' , color : view === v ? 'var(--text-primary)' : 'var(--text-tertiary)' , boxShadow : view === v ? '0 1px 3px rgba(0,0,0,0.1)' : 'none' , transition : 'all 0.15s' , whiteSpace : 'nowrap' } } >
{ labels [ v ] }
< / button >
) ;
@@ -1199,6 +1220,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
onFilterKeyword = { setFilterKeyword }
filterTypeId = { filterTypeId }
onFilterTypeId = { setFilterTypeId }
filterAvailability = { filterAvailability }
onFilterAvailability = { setFilterAvailability }
onInputFocus = { ( ) => setInputFocused ( true ) }
onInputBlur = { ( ) => setInputFocused ( false ) }
eventDates = { eventDates }
@@ -1211,7 +1234,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
{ /* Calendar or panel content */ }
< div style = { { flex : 1 , overflowY : 'auto' , overflowX : panel === 'eventForm' ? 'auto' : 'hidden' } } >
{ panel === 'calendar' && view === 'schedule' && < div style = { { paddingBottom : isMobile ? 80 : 0 } } > < ScheduleView events = { events } selectedDate = { selDate } onSelect = { openDetail } filterKeyword = { filterKeyword } filterTypeId = { filterTypeId } isMobile = { isMobile } / > < / div > }
{ panel === 'calendar' && view === 'schedule' && < div style = { { paddingBottom : isMobile ? 80 : 0 } } > < ScheduleView events = { events } selectedDate = { selDate } onSelect = { openDetail } filterKeyword = { filterKeyword } filterTypeId = { filterTypeId } filterAvailability = { filterAvailability } isMobile = { isMobile } / > < / div > }
{ panel === 'calendar' && view === 'day' && < DayView events = { events } selectedDate = { selDate } onSelect = { openDetail } onSwipe = { isMobile ? dir => { const d = new Date ( selDate ) ; d . setDate ( d . getDate ( ) + dir ) ; setSelDate ( d ) ; } : undefined } / > }
{ panel === 'calendar' && view === 'week' && < WeekView events = { events } selectedDate = { selDate } onSelect = { openDetail } / > }
{ panel === 'calendar' && view === 'month' && < MonthView events = { events } selectedDate = { selDate } onSelect = { openDetail } onSelectDay = { d => { setSelDate ( d ) ; setView ( 'schedule' ) ; } } / > }