v0.9.51 minor schedule bug fixes.
This commit is contained in:
@@ -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' }}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user