v0.9.52 minor bug fixes

This commit is contained in:
2026-03-17 15:24:29 -04:00
parent 5d21420ed9
commit e4f5504e52
5 changed files with 77 additions and 51 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.51 JAMA_VERSION=0.9.52
# 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.51", "version": "0.9.52",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.9.51}" VERSION="${1:-0.9.52}"
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.51", "version": "0.9.52",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -159,25 +159,45 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [localTypes,setLocalTypes]=useState(eventTypes); const [localTypes,setLocalTypes]=useState(eventTypes);
const typeRef=useRef(null); const typeRef=useRef(null);
// When event type changes: auto set duration and default group // Track whether the user has manually changed the end time (vs auto-computed)
const userSetEndTime = useRef(!!event); // editing mode: treat saved end as user-set
// When event type changes:
// - Creating: always apply the type's duration to compute end time
// - Editing: only apply duration if the type HAS a defined duration
// (if no duration on type, keep existing saved end time)
useEffect(()=>{ useEffect(()=>{
if(!sd||!st) return; if(!sd||!st) return;
const typ=localTypes.find(t=>t.id===Number(typeId)); const typ=localTypes.find(t=>t.id===Number(typeId));
const dur=typ?.default_duration_hrs||1;
const start=buildISO(sd,st); const start=buildISO(sd,st);
if(start){ if(!start) return;
if(!event) {
// Creating new event — always apply duration (default 1hr)
const dur=typ?.default_duration_hrs||1;
setEd(toDateIn(addHours(start,dur))); setEd(toDateIn(addHours(start,dur)));
setEt(toTimeIn(addHours(start,dur))); setEt(toTimeIn(addHours(start,dur)));
userSetEndTime.current = false;
} else {
// Editing — only update end time if the new type has an explicit duration
if(typ?.default_duration_hrs) {
setEd(toDateIn(addHours(start,typ.default_duration_hrs)));
setEt(toTimeIn(addHours(start,typ.default_duration_hrs)));
userSetEndTime.current = false;
}
// else: keep existing saved end time — do nothing
} }
if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)])); if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)]));
},[typeId]); },[typeId]);
// When start date changes: auto-match end date // When start date changes: match end date (both modes) unless user set it manually
useEffect(()=>{ if(!event) setEd(sd); },[sd]); useEffect(()=>{
if(!userSetEndTime.current) setEd(sd);
},[sd]);
// When start time changes: auto-update end time preserving duration // When start time changes: recompute end using current duration offset
useEffect(()=>{ useEffect(()=>{
if(!sd||!st) return; if(!sd||!st) return;
if(userSetEndTime.current) return; // user already picked a specific end time — respect it
const typ=localTypes.find(t=>t.id===Number(typeId)); const typ=localTypes.find(t=>t.id===Number(typeId));
const dur=typ?.default_duration_hrs||1; const dur=typ?.default_duration_hrs||1;
const start=buildISO(sd,st); const start=buildISO(sd,st);
@@ -238,10 +258,10 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)} {TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select> </select>
<span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span> <span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span>
<select className="input" value={et} onChange={e=>setEt(e.target.value)} style={{width:120,flexShrink:0}}> <select className="input" value={et} onChange={e=>{setEt(e.target.value);userSetEndTime.current=true;}} style={{width:120,flexShrink:0}}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)} {TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select> </select>
<input type="date" className="input" value={ed} onChange={e=>setEd(e.target.value)} style={{width:150,flexShrink:0}}/> <input type="date" className="input" value={ed} onChange={e=>{setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/>
</> </>
)} )}
</div> </div>
@@ -467,11 +487,13 @@ function ScheduleView({ events, selectedDate, onSelect }) {
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>);})}</>; 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 const HOUR_H = 52; // px per hour row
const DAY_START = 0; // show from midnight
const DAY_END = 24; // to midnight
function eventTopOffset(startDate) { function eventTopOffset(startDate) {
const h=startDate.getHours(), m=startDate.getMinutes(); const h=startDate.getHours(), m=startDate.getMinutes();
return (h-7)*HOUR_H + (m/60)*HOUR_H; return (h - DAY_START)*HOUR_H + (m/60)*HOUR_H;
} }
function eventHeightPx(startDate, endDate) { function eventHeightPx(startDate, endDate) {
const diffMs=endDate-startDate; const diffMs=endDate-startDate;
@@ -480,41 +502,42 @@ function eventHeightPx(startDate, endDate) {
} }
function DayView({ events, selectedDate, onSelect }) { function DayView({ events, selectedDate, onSelect }) {
const hours=Array.from({length:16},(_,i)=>i+7); // 7am10pm const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START);
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate)); const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
const scrollRef = useRef(null);
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
return( return(
<div> <div style={{display:'flex',flexDirection:'column',height:'100%'}}>
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)'}}> <div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)',flexShrink:0}}>
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div> <div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
</div> </div>
<div style={{position:'relative'}}> <div ref={scrollRef} style={{flex:1,overflowY:'auto',position:'relative'}}>
{/* Hour grid */} <div style={{position:'relative'}}>
{hours.map(h=>( {hours.map(h=>(
<div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',height:HOUR_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'}}> <div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{fmtHour(h)}</div>
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`} <div style={{flex:1}}/>
</div> </div>
<div style={{flex:1}}/> ))}
</div> {day.map(e=>{
))} const s=new Date(e.start_at), en=new Date(e.end_at);
{/* Event blocks — absolutely positioned */} const top=eventTopOffset(s), height=eventHeightPx(s,en);
{day.map(e=>{ return(
const s=new Date(e.start_at), en=new Date(e.end_at); <div key={e.id} onClick={()=>onSelect(e)} style={{
const top=eventTopOffset(s), height=eventHeightPx(s,en); position:'absolute', left:64, right:8,
return( top, height,
<div key={e.id} onClick={()=>onSelect(e)} style={{ background:e.event_type?.colour||'#6366f1', color:'white',
position:'absolute', left:64, right:8, borderRadius:5, padding:'3px 8px', cursor:'pointer',
top, height, fontSize:12, fontWeight:600, overflow:'hidden',
background:e.event_type?.colour||'#6366f1', color:'white', boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
borderRadius:5, padding:'3px 8px', cursor:'pointer', }}>
fontSize:12, fontWeight:600, overflow:'hidden', <div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
boxShadow:'0 1px 3px rgba(0,0,0,0.2)', {height>32&&<div style={{fontSize:10,opacity:0.85}}>{fmtRange(e.start_at,e.end_at)}</div>}
}}> </div>
<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> </div>
</div> </div>
); );
@@ -522,22 +545,24 @@ function DayView({ events, selectedDate, onSelect }) {
function WeekView({ events, selectedDate, onSelect }) { 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 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(); const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date();
const scrollRef = useRef(null);
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
return( return(
<div> <div style={{display:'flex',flexDirection:'column',height:'100%'}}>
{/* Day headers */} {/* 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 style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',background:'var(--surface)',flexShrink:0}}>
<div/> <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>)} {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> </div>
{/* Time grid with event columns */} {/* Scrollable time grid */}
<div ref={scrollRef} style={{flex:1,overflowY:'auto'}}>
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',position:'relative'}}> <div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',position:'relative'}}>
{/* Time labels column */} {/* Time labels column */}
<div> <div>
{hours.map(h=>( {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'}}> <div key={h} style={{height:HOUR_H,borderBottom:'1px solid var(--border)',fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{fmtHour(h)}</div>
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
</div>
))} ))}
</div> </div>
{/* Day columns */} {/* Day columns */}
@@ -566,6 +591,7 @@ function WeekView({ events, selectedDate, onSelect }) {
); );
})} })}
</div> </div>
</div>
</div> </div>
); );
} }