v0.9.60 Ramsey recommended feature adds

This commit is contained in:
2026-03-17 18:43:25 -04:00
parent c823c86b63
commit b2b09cb0d0
6 changed files with 90 additions and 22 deletions

View File

@@ -10,7 +10,7 @@
PROJECT_NAME=jama PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest') # Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.9.59 JAMA_VERSION=0.9.60
# App port — the host port Docker maps to the container # App port — the host port Docker maps to the container
PORT=3000 PORT=3000

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama-backend", "name": "jama-backend",
"version": "0.9.59", "version": "0.9.60",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -432,21 +432,19 @@ function initDb() {
// Migration: add columns if missing (must run before inserts) // Migration: add columns if missing (must run before inserts)
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {} try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {} try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
// Delete the legacy "Default" type — "Event" is the canonical default
db.prepare("DELETE FROM event_types WHERE name = 'Default'").run();
// Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete) // Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete)
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, 1, 1.0)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, 1, 1.0)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
// Rename legacy "Default" to "Event" if it exists // Remove duplicates — keep the one with is_default=1
db.prepare("UPDATE event_types SET name = 'Event', is_protected = 1, default_duration_hrs = 1.0 WHERE name = 'Default'").run();
// Remove the old separate "Event" type if it was created before this migration (was a duplicate)
// Keep whichever has is_default=1; delete duplicates
const evtTypes = db.prepare("SELECT id, is_default FROM event_types WHERE name = 'Event' ORDER BY is_default DESC").all(); const evtTypes = db.prepare("SELECT id, is_default FROM event_types WHERE name = 'Event' ORDER BY is_default DESC").all();
if (evtTypes.length > 1) { if (evtTypes.length > 1) {
for (let i=1; i<evtTypes.length; i++) db.prepare('DELETE FROM event_types WHERE id = ?').run(evtTypes[i].id); for (let i=1; i<evtTypes.length; i++) db.prepare('DELETE FROM event_types WHERE id = ?').run(evtTypes[i].id);
} }
// Ensure built-in types are protected // Ensure built-in types are correct
db.prepare("UPDATE event_types SET is_protected = 1 WHERE name IN ('Event')").run(); db.prepare("UPDATE event_types SET is_protected = 1, is_default = 1, default_duration_hrs = 1.0 WHERE name = 'Event'").run();
// Ensure Game/Practice have correct durations
db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game'").run(); db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game'").run();
db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice'").run(); db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice'").run();
console.log('[DB] Schedule Manager tables ready'); console.log('[DB] Schedule Manager tables ready');

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.9.59}" VERSION="${1:-0.9.60}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama" IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama-frontend", "name": "jama-frontend",
"version": "0.9.59", "version": "0.9.60",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -28,6 +28,32 @@ function daysInMonth(y,m){ return new Date(y,m+1,0).getDate(); }
const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' }; const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' };
const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' }; const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' };
const RESP_ICON = {
going: (color,size=15) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
<title>Going</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
),
maybe: (color,size=15) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
<title>Maybe</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
),
not_going: (color,size=15) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
<title>Not Going</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
),
};
const BELL_ICON = (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="#ef4444" width={15} height={15} style={{flexShrink:0}}>
<title>Awaiting your response</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
);
// 30-minute time slots // 30-minute time slots
const TIME_SLOTS = (() => { const TIME_SLOTS = (() => {
@@ -170,7 +196,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [showTypeForm,setShowTypeForm]=useState(false); const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes); const [localTypes,setLocalTypes]=useState(eventTypes);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load) // Sync localTypes when parent provides updated eventTypes (e.g. after async load)
useEffect(()=>{ setLocalTypes(eventTypes); },[eventTypes]); // Also initialise typeId to the default event type for new events
useEffect(()=>{
setLocalTypes(eventTypes);
if(!event && typeId==='' && eventTypes.length>0) {
const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
if(def) setTypeId(String(def.id));
}
},[eventTypes]);
const typeRef=useRef(null); const typeRef=useRef(null);
// Track whether the user has manually changed the end time (vs auto-computed) // Track whether the user has manually changed the end time (vs auto-computed)
@@ -373,7 +406,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div> </div>
<div style={{fontSize:13,color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:8}}> <div style={{fontSize:13,color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:8}}>
{event.event_type?.name&&<span>{event.event_type.name}</span>} {event.event_type?.name&&<span>{event.event_type.name}</span>}
{!event.is_public&&<span style={{background:'var(--surface-variant)',borderRadius:10,padding:'1px 8px',fontSize:11}}>Private</span>} {event.is_public
? <span style={{color:'#22c55e',fontWeight:600,fontSize:12}}>Public Event</span>
: <span style={{color:'#ef4444',fontWeight:600,fontSize:12}}>Private Event</span>}
</div> </div>
</div> </div>
<div style={{display:'flex',gap:6,flexShrink:0}}> <div style={{display:'flex',gap:6,flexShrink:0}}>
@@ -482,19 +517,22 @@ function BulkImportPanel({ onImported, onCancel }) {
} }
// ── Calendar Views ──────────────────────────────────────────────────────────── // ── Calendar Views ────────────────────────────────────────────────────────────
function ScheduleView({ events, selectedDate, onSelect }) { function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='' }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth(); 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 monthStart=new Date(y,m,1), monthEnd=new Date(y,m+1,0,23,59,59);
const kw=filterKeyword.toLowerCase().trim();
const filtered=events.filter(e=>{ const filtered=events.filter(e=>{
const s=new Date(e.start_at); const s=new Date(e.start_at);
return s>=monthStart && s<=monthEnd; if(s<monthStart||s>monthEnd) return false;
if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false;
if(kw && ![
e.title||'', e.location||'', e.description||''
].some(f=>f.toLowerCase().includes(kw))) return false;
return true;
}); });
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>No events in {MONTHS[m]} {y}</div>; if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{kw||filterTypeId?'No events match your filters':'No events in'} {!kw&&!filterTypeId&&`${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&&( 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&&(
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="#ef4444" width={15} height={15} style={{flexShrink:0}}> e.my_response ? RESP_ICON[e.my_response](RESP_COLOR[e.my_response]) : BELL_ICON
<title>Awaiting your response</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
)}</div>{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}</div></div>);})}</>; )}</div>{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}</div></div>);})}</>;
} }
@@ -708,6 +746,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const [userGroups, setUserGroups] = useState([]); const [userGroups, setUserGroups] = useState([]);
const [panel, setPanel] = useState('calendar'); const [panel, setPanel] = useState('calendar');
const [editingEvent, setEditingEvent] = useState(null); const [editingEvent, setEditingEvent] = useState(null);
const [filterKeyword, setFilterKeyword] = useState('');
const [filterTypeId, setFilterTypeId] = useState('');
const [detailEvent, setDetailEvent] = useState(null); const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -797,6 +837,36 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<div className="section-label" style={{ marginBottom:8 }}>Filter Events</div> <div className="section-label" style={{ marginBottom:8 }}>Filter Events</div>
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/> <MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
</div> </div>
{/* List view filters — only shown in Schedule list view */}
{view==='schedule' && panel==='calendar' && (
<div style={{ padding:'0 16px 16px' }}>
<div className="section-label" style={{ marginBottom:8 }}>Search</div>
<input
className="input"
placeholder="Keyword…"
value={filterKeyword}
onChange={e=>setFilterKeyword(e.target.value)}
style={{ marginBottom:8, fontSize:13 }}
/>
<select
className="input"
value={filterTypeId}
onChange={e=>setFilterTypeId(e.target.value)}
style={{ fontSize:13 }}
>
<option value="">All event types</option>
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{(filterKeyword||filterTypeId) && (
<button
className="btn btn-secondary btn-sm"
onClick={()=>{setFilterKeyword('');setFilterTypeId('');}}
style={{ marginTop:8, width:'100%' }}
>Clear filters</button>
)}
</div>
)}
<div style={{ flex:1 }}/> <div style={{ flex:1 }}/>
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} /> <UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div> </div>
@@ -843,7 +913,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
{/* Calendar or panel content */} {/* Calendar or panel content */}
<div style={{ flex:1, overflowY:'auto', overflowX: panel==='eventForm'?'auto':'hidden' }}> <div style={{ flex:1, overflowY:'auto', overflowX: panel==='eventForm'?'auto':'hidden' }}>
{panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail}/>} {panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId}/>}
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail}/>} {panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>} {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');}}/>} {panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('schedule');}}/>}