v0.9.85.1 updates

This commit is contained in:
2026-03-19 11:29:30 -04:00
parent 6b7d555879
commit 6169ad5d99
5 changed files with 146 additions and 53 deletions

View File

@@ -176,7 +176,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule } = req.body;
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
@@ -194,6 +194,39 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
req.params.id
);
// For recurring events: if scope='future', update all future occurrences too
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = db.prepare(`
SELECT id FROM events
WHERE id != ? AND created_by = ? AND recurrence_rule IS NOT NULL
AND start_at >= ? AND title = ?
`).all(req.params.id, event.created_by, event.start_at, event.title);
for (const fe of futureEvents) {
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
location = ?, description = ?, is_public = COALESCE(?, is_public),
track_availability = COALESCE(?, track_availability),
recurrence_rule = ?,
updated_at = datetime('now')
WHERE id = ?`).run(
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
fe.id
);
if (Array.isArray(userGroupIds)) {
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(fe.id);
for (const ugId of userGroupIds)
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(fe.id, ugId);
}
}
}
if (Array.isArray(userGroupIds)) {
// Find which groups are being removed
const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?')

View File

@@ -22,18 +22,11 @@
"purpose": "maskable"
},
{
"purpose": "maskable",
"purpose": "any maskable",
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"min_width": "320px"
}

View File

@@ -0,0 +1,39 @@
{
"name": "jama",
"short_name": "jama",
"description": "Modern team messaging application",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#1a73e8",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"purpose": "maskable",
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"min_width": "320px"
}

View File

@@ -253,7 +253,12 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
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, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
const r = event ? await api.updateEvent(event.id, body) : await api.createEvent(body);
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); }

View File

@@ -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:'2px 6px',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:'6px 8px',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:'2px 6px',lineHeight:1}}></button>
<button onClick={()=>onMonthChange(1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'6px 8px',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
value={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)}
style={{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)',height:MONTH_CELL_H,background:'var(--surface-variant)'}}/>;
if(!d) return <div key={di} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',minHeight: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'}}
<div key={di} onClick={()=>onSelectDay(date)} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',minHeight: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');}}/>}