diff --git a/.env.example b/.env.example index d465d80..8cd5ef1 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ PROJECT_NAME=jama # 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 PORT=3000 diff --git a/backend/package.json b/backend/package.json index 8b3fc6d..73d013a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.51", + "version": "0.9.52", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 9eb598d..b5b80e5 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.51}" +VERSION="${1:-0.9.52}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 141f6ef..11d7375 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.51", + "version": "0.9.52", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 9d08880..302a1ac 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -159,25 +159,45 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc const [localTypes,setLocalTypes]=useState(eventTypes); 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(()=>{ if(!sd||!st) return; const typ=localTypes.find(t=>t.id===Number(typeId)); - const dur=typ?.default_duration_hrs||1; 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))); 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)])); },[typeId]); - // When start date changes: auto-match end date - useEffect(()=>{ if(!event) setEd(sd); },[sd]); + // When start date changes: match end date (both modes) unless user set it manually + 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(()=>{ 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 dur=typ?.default_duration_hrs||1; const start=buildISO(sd,st); @@ -238,10 +258,10 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc {TIME_SLOTS.map(s=>)} to - {setEt(e.target.value);userSetEndTime.current=true;}} style={{width:120,flexShrink:0}}> {TIME_SLOTS.map(s=>)} - setEd(e.target.value)} style={{width:150,flexShrink:0}}/> + {setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/> )} @@ -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(
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=''}>
{s.getDate()}
{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}
{e.all_day?'All day':fmtRange(e.start_at,e.end_at)}
{e.event_type?.name&&{e.event_type.name}:}{e.title}{e.track_availability&&!e.my_response&&}
{e.location&&
{e.location}
}
);})}; } -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) { 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) { const diffMs=endDate-startDate; @@ -480,41 +502,42 @@ function eventHeightPx(startDate, endDate) { } function DayView({ events, selectedDate, onSelect }) { - const hours=Array.from({length:16},(_,i)=>i+7); // 7am–10pm + 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 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( -
-
+
+
{DAYS[selectedDate.getDay()]}
{selectedDate.getDate()}
-
- {/* Hour grid */} - {hours.map(h=>( -
-
- {h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`} +
+
+ {hours.map(h=>( +
+
{fmtHour(h)}
+
-
-
- ))} - {/* 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( -
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)', - }}> -
{e.title}
- {height>32&&
{fmtRange(e.start_at,e.end_at)}
} -
- ); - })} + ))} + {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( +
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)', + }}> +
{e.title}
+ {height>32&&
{fmtRange(e.start_at,e.end_at)}
} +
+ ); + })} +
); @@ -522,22 +545,24 @@ function DayView({ 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 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( -
+
{/* Day headers */} -
+
{days.map((d,i)=>
{DAYS[d.getDay()]} {d.getDate()}
)}
- {/* Time grid with event columns */} + {/* Scrollable time grid */} +
{/* Time labels column */}
{hours.map(h=>( -
- {h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`} -
+
{fmtHour(h)}
))}
{/* Day columns */} @@ -566,6 +591,7 @@ function WeekView({ events, selectedDate, onSelect }) { ); })}
+
); }