diff --git a/.env.example b/.env.example index 41daf30..5f93453 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.48 +JAMA_VERSION=0.9.49 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index c31af63..27fe239 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.48", + "version": "0.9.49", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 6b804ff..690de55 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -429,15 +429,19 @@ function initDb() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `); + // 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 default_duration_hrs REAL"); } catch(e) {} + // Seed built-in event types db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected) VALUES ('Default', '#9ca3af', 1, 1)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, NULL)").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(); - // Migration: add is_protected if missing - 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) {} - // Ensure built-in types are protected + // Ensure built-in types are protected (idempotent) db.prepare("UPDATE event_types SET is_protected = 1 WHERE name IN ('Default', 'Event')").run(); + // Ensure Game/Practice have correct durations if they already existed without them + db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game' AND default_duration_hrs IS NULL").run(); + db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice' AND default_duration_hrs IS NULL").run(); console.log('[DB] Schedule Manager tables ready'); } catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); } diff --git a/build.sh b/build.sh index efc80b7..3b7753e 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.48}" +VERSION="${1:-0.9.49}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index c8c13c6..3469814 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.48", + "version": "0.9.49", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 2239434..7c5ded2 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx'; +import UserFooter from './UserFooter.jsx'; // ── Utilities ───────────────────────────────────────────────────────────────── const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; @@ -153,21 +154,33 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc const [localTypes,setLocalTypes]=useState(eventTypes); const typeRef=useRef(null); - // Auto end time when type changes (only for new events) + // When event type changes: auto set duration and default group useEffect(()=>{ - if(!typeId||event) return; + if(!sd||!st) return; const typ=localTypes.find(t=>t.id===Number(typeId)); - if(!typ?.default_duration_hrs||!sd||!st) return; + const dur=typ?.default_duration_hrs||1; const start=buildISO(sd,st); - setEd(toDateIn(addHours(start,typ.default_duration_hrs))); - setEt(toTimeIn(addHours(start,typ.default_duration_hrs))); - if(typ.default_user_group_id) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)])); + if(start){ + setEd(toDateIn(addHours(start,dur))); + setEt(toTimeIn(addHours(start,dur))); + } + if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)])); },[typeId]); - // Auto-match end date to start date when start date changes + // When start date changes: auto-match end date + useEffect(()=>{ if(!event) setEd(sd); },[sd]); + + // When start time changes: auto-update end time preserving duration useEffect(()=>{ - if(!event) setEd(sd); - },[sd]); + 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){ + setEd(toDateIn(addHours(start,dur))); + setEt(toTimeIn(addHours(start,dur))); + } + },[st]); const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;}); const groupsRequired=track; // when tracking, groups are required @@ -198,7 +211,49 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/> - {/* Availability (first — if enabled, groups become required) */} + {/* Event Type */} + +
+ + {isToolManager&&} + {showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>} +
+
+ + {/* Date/Time */} + +
+
+ setSd(e.target.value)} style={{width:150,flexShrink:0}}/> + {!allDay&&( + <> + + to + + setEd(e.target.value)} style={{width:150,flexShrink:0}}/> + + )} +
+
+ + +
+
+
+ + {/* Availability */} )} - {/* Date/Time */} - -
-
- setSd(e.target.value)} style={{width:150,flexShrink:0}}/> - {!allDay&&( - <> - - to - - setEd(e.target.value)} style={{width:150,flexShrink:0}}/> - - )} -
- -
-
- - {/* Event Type */} - -
- - {isToolManager&&} - {showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>} -
-
- {/* Location */} setLoc(e.target.value)}/> @@ -298,11 +317,19 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool const toast=useToast(); const [myResp,setMyResp]=useState(event.my_response); const [avail,setAvail]=useState(event.availability||[]); + // Sync when parent reloads event after availability change + useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]); const counts={going:0,maybe:0,not_going:0}; avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;}); const handleResp=async resp=>{ - try{if(myResp===resp){await api.deleteAvailability(event.id);setMyResp(null);}else{await api.setAvailability(event.id,resp);setMyResp(resp);}onAvailabilityChange?.();}catch(e){toast(e.message,'error');} + const prev=myResp; + const next=myResp===resp?null:resp; + setMyResp(next); // optimistic update + try{ + if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp);} + onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list + }catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error }; return ReactDOM.createPortal( @@ -521,7 +548,7 @@ export default function SchedulePage({ isToolManager, isMobile }) { {/* Left panel — matches sidebar width */} {!isMobile && (
-
+
Team Schedule
{/* Create button — styled like new-chat-btn */} @@ -550,9 +577,12 @@ export default function SchedulePage({ isToolManager, isMobile }) {
{/* Mini calendar */} -
+
+
Filter Events
{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
+
+
)} @@ -637,7 +667,11 @@ export default function SchedulePage({ isToolManager, isMobile }) { isToolManager={isToolManager} onClose={() => setDetailEvent(null)} onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }} - onAvailabilityChange={() => openDetail(detailEvent)} + onAvailabilityChange={(resp) => { + // Update the list so the "awaiting response" dot disappears immediately + setEvents(prev => prev.map(e => e.id === detailEvent.id ? {...e, my_response: resp} : e)); + openDetail(detailEvent); + }} /> )}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index fbf269f..d08f913 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -5,15 +5,7 @@ import { api, parseTS } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from './Avatar.jsx'; import './Sidebar.css'; - -function useTheme() { - const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark'); - useEffect(() => { - document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); - localStorage.setItem('jama-theme', dark ? 'dark' : 'light'); - }, [dark]); - return [dark, setDark]; -} +import UserFooter from './UserFooter.jsx'; function useAppSettings() { const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' }); @@ -52,14 +44,10 @@ function formatTime(dateStr) { } export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupManager, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set(), features = {} }) { - const { user, logout } = useAuth(); + const { user } = useAuth(); const { connected } = useSocket(); const toast = useToast(); - const [showMenu, setShowMenu] = useState(false); const settings = useAppSettings(); - const [dark, setDark] = useTheme(); - const menuRef = useRef(null); - const footerBtnRef = useRef(null); useEffect(() => { if (!showMenu) return; @@ -92,7 +80,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica }); const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length; - const handleLogout = async () => { await logout(); }; const GroupItem = ({ group }) => { const notifs = getNotifCount(group.id); @@ -189,62 +176,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica )} -
-
- - -
- - {showMenu && ( -
- - - -
- -
- )} -
+
); -} +} \ No newline at end of file diff --git a/frontend/src/components/UserFooter.jsx b/frontend/src/components/UserFooter.jsx new file mode 100644 index 0000000..be917dd --- /dev/null +++ b/frontend/src/components/UserFooter.jsx @@ -0,0 +1,88 @@ +import { useState, useRef, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext.jsx'; +import Avatar from './Avatar.jsx'; + +function useTheme() { + const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark'); + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); + localStorage.setItem('jama-theme', dark ? 'dark' : 'light'); + }, [dark]); + return [dark, setDark]; +} + +export default function UserFooter({ onProfile, onHelp, onAbout }) { + const { user, logout } = useAuth(); + const [showMenu, setShowMenu] = useState(false); + const [dark, setDark] = useTheme(); + const menuRef = useRef(null); + const btnRef = useRef(null); + + useEffect(() => { + if (!showMenu) return; + const handler = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target) && + btnRef.current && !btnRef.current.contains(e.target)) { + setShowMenu(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showMenu]); + + const handleLogout = async () => { await logout(); }; + + return ( +
+
+ + +
+ + {showMenu && ( +
+ + + +
+ +
+ )} +
+ ); +}