diff --git a/.env.example b/.env.example
index 4fceddd..7959020 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.62
+JAMA_VERSION=0.9.63
# App port — the host port Docker maps to the container
PORT=3000
diff --git a/backend/package.json b/backend/package.json
index bac9e70..2e43614 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-backend",
- "version": "0.9.62",
+ "version": "0.9.63",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
index 8750ba0..093d4f9 100644
--- a/backend/src/models/db.js
+++ b/backend/src/models/db.js
@@ -432,6 +432,7 @@ function initDb() {
// 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) {}
+ try { db.exec("ALTER TABLE events ADD COLUMN recurrence_rule TEXT"); } 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)
diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js
index 3adaa4b..016e384 100644
--- a/backend/src/routes/schedule.js
+++ b/backend/src/routes/schedule.js
@@ -36,6 +36,9 @@ function enrichEvent(db, event) {
event.event_type = event.event_type_id
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
: null;
+ if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
+ try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
+ }
event.user_groups = db.prepare(`
SELECT ug.id, ug.name FROM event_user_groups eug
JOIN user_groups ug ON ug.id = eug.user_group_id
@@ -150,15 +153,16 @@ router.get('/:id', authMiddleware, (req, res) => {
// Create event
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
- const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [] } = req.body;
+ const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [], recurrenceRule } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
const db = getDb();
- const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, created_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
+ const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, recurrence_rule, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
title.trim(), eventTypeId || null, startAt, endAt,
allDay ? 1 : 0, location || null, description || null,
- isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0, req.user.id
+ isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
+ recurrenceRule ? JSON.stringify(recurrenceRule) : null, req.user.id
);
const eventId = r.lastInsertRowid;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
@@ -172,12 +176,14 @@ 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 } = req.body;
+ const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule } = 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),
location = ?, description = ?, is_public = COALESCE(?, is_public),
- track_availability = COALESCE(?, track_availability), updated_at = datetime('now')
+ 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,
@@ -185,6 +191,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
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,
req.params.id
);
if (Array.isArray(userGroupIds)) {
diff --git a/build.sh b/build.sh
index 1ad8727..ae3ea86 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.9.62}"
+VERSION="${1:-0.9.63}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"
diff --git a/frontend/package.json b/frontend/package.json
index 2df5d3c..c442a25 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
- "version": "0.9.62",
+ "version": "0.9.63",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx
new file mode 100644
index 0000000..b9deec5
--- /dev/null
+++ b/frontend/src/components/MobileEventForm.jsx
@@ -0,0 +1,356 @@
+import { useState, useEffect, useRef } from 'react';
+import { api } from '../utils/api.js';
+import { useToast } from '../contexts/ToastContext.jsx';
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
+const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
+const SHORT_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+const DAY_PILLS = ['S','M','T','W','T','F','S'];
+const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
+
+const TIME_SLOTS = (() => {
+ const s=[];
+ for(let h=0;h<24;h++) for(let m of [0,30]) {
+ const hh=String(h).padStart(2,'0'), mm=String(m).padStart(2,'0');
+ const disp=`${h===0?12:h>12?h-12:h}:${mm} ${h<12?'AM':'PM'}`;
+ s.push({value:`${hh}:${mm}`,label:disp});
+ }
+ return s;
+})();
+
+function toDateIn(iso) { return iso ? iso.slice(0,10) : ''; }
+function toTimeIn(iso) {
+ if(!iso) return '';
+ const d=new Date(iso);
+ const h=String(d.getHours()).padStart(2,'0'), m=d.getMinutes()<30?'00':'30';
+ return `${h}:${m}`;
+}
+function buildISO(d,t) { return d&&t?`${d}T${t}:00`:''; }
+function addHours(iso,h){ const d=new Date(iso); d.setMinutes(d.getMinutes()+h*60); const pad=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; }
+function fmtDateDisplay(iso) { if(!iso) return ''; const d=new Date(iso); return `${DAYS[d.getDay()]}, ${SHORT_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; }
+function fmtTimeDisplay(slot) { const f=TIME_SLOTS.find(s=>s.value===slot); return f?f.label:slot; }
+
+const FREQ_OPTIONS = [
+ { value: '', label: 'Does not repeat' },
+ { value: 'daily', label: 'Every day' },
+ { value: 'weekly', label: 'Every week' },
+ { value: 'monthly', label: 'Every month' },
+ { value: 'yearly', label: 'Every year' },
+ { value: 'custom', label: 'Custom…' },
+];
+function recurrenceLabel(rule) {
+ if (!rule || !rule.freq) return 'Does not repeat';
+ if (rule.freq === 'custom') { const unit = (rule.interval||1)===1 ? rule.unit : `${rule.interval} ${rule.unit}s`; return `Every ${unit}`; }
+ return FREQ_OPTIONS.find(o=>o.value===rule.freq)?.label || rule.freq;
+}
+
+// ── Toggle Switch ─────────────────────────────────────────────────────────────
+function Toggle({ checked, onChange }) {
+ return (
+
onChange(!checked)} style={{ width:44,height:24,borderRadius:12,background:checked?'var(--primary)':'var(--surface-variant)',cursor:'pointer',position:'relative',transition:'background 0.2s',flexShrink:0 }}>
+
+
+ );
+}
+
+// ── Calendar Picker Overlay ───────────────────────────────────────────────────
+function CalendarPicker({ value, onChange, onClose }) {
+ const [cur, setCur] = useState(() => { const d = new Date(value||Date.now()); d.setDate(1); return d; });
+ const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=new Date(y,m+1,0).getDate(), today=new Date();
+ const cells=[]; for(let i=0;ie.target===e.currentTarget&&onClose()}>
+
+
Select Date
+
+ {selDate ? `${SHORT_MONTHS[selDate.getMonth()]} ${selDate.getDate()}, ${selDate.getFullYear()}` : '—'}
+
+
+
+ {MONTHS[m]} {y}
+
+
+
+ {['S','M','T','W','T','F','S'].map((d,i)=>
{d}
)}
+ {cells.map((d,i) => {
+ if(!d) return
;
+ const date=new Date(y,m,d);
+ const isSel = selDate && date.toDateString()===selDate.toDateString();
+ const isToday = date.toDateString()===today.toDateString();
+ return
onChange(`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`)} style={{ textAlign:'center',padding:'8px 4px',borderRadius:'50%',cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,fontSize:14 }}>{d}
;
+ })}
+
+
+
+
+
+
+
+ );
+}
+
+// ── Recurrence Sheet ──────────────────────────────────────────────────────────
+function RecurrenceSheet({ value, onChange, onClose }) {
+ const rule = value || {};
+ const [showCustom, setShowCustom] = useState(rule.freq==='custom');
+ const [customRule, setCustomRule] = useState(rule.freq==='custom' ? rule : {freq:'custom',interval:1,unit:'week',byDay:[],ends:'never',endDate:'',endCount:13});
+
+ const selectFreq = (freq) => {
+ if(freq==='custom') { setShowCustom(true); return; }
+ onChange(freq ? {freq} : null);
+ onClose();
+ };
+ const upd = (k,v) => setCustomRule(r=>({...r,[k]:v}));
+
+ if(showCustom) return (
+
+
+
+
+
Custom recurrence
+
+
+
+
+
Repeats every
+
+ upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{ width:70,textAlign:'center',fontSize:16 }}/>
+
+
+
+
+ {(customRule.unit||'week')==='week' && (
+
+
Repeats on
+
+ {DAY_PILLS.map((d,i)=>{
+ const key=DAY_KEYS[i], sel=(customRule.byDay||[]).includes(key);
+ return ;
+ })}
+
+
+ )}
+
+
+
Ends
+ {[['never','Never'],['on','On'],['after','After']].map(([val,lbl])=>(
+
+
upd('ends',val)} style={{ width:20,height:20,borderRadius:'50%',border:`2px solid ${(customRule.ends||'never')===val?'var(--primary)':'var(--border)'}`,display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',flexShrink:0 }}>
+ {(customRule.ends||'never')===val&&
}
+
+
{lbl}
+ {val==='on'&&(customRule.ends||'never')==='on'&&
upd('endDate',e.target.value)} style={{ width:150 }}/>}
+ {val==='after'&&(customRule.ends||'never')==='after'&&<>
upd('endCount',parseInt(e.target.value)||1)} style={{ width:64,textAlign:'center' }}/>
occurrences>}
+
+ ))}
+
+
+
+ );
+
+ return (
+ e.target===e.currentTarget&&onClose()}>
+
+ {FREQ_OPTIONS.map(opt=>(
+
selectFreq(opt.value)} style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 4px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
+
+ {(rule.freq||'')===(opt.value)&&
}
+
+
{opt.label}
+
+ ))}
+
+
+ );
+}
+
+// ── Main Mobile Event Form ────────────────────────────────────────────────────
+export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
+ const toast = useToast();
+ const def = selectedDate ? selectedDate.toISOString().slice(0,10) : new Date().toISOString().slice(0,10);
+ const [title, setTitle] = useState(event?.title||'');
+ const [typeId, setTypeId] = useState(event?.event_type_id ? String(event.event_type_id) : '');
+ const [sd, setSd] = useState(event ? toDateIn(event.start_at) : def);
+ const [st, setSt] = useState(event ? toTimeIn(event.start_at) : '09:00');
+ const [ed, setEd] = useState(event ? toDateIn(event.end_at) : def);
+ const [et, setEt] = useState(event ? toTimeIn(event.end_at) : '10:00');
+ const [allDay, setAllDay] = useState(!!event?.all_day);
+ const [track, setTrack] = useState(!!event?.track_availability);
+ const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false);
+ const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
+ const [location, setLocation] = useState(event?.location||'');
+ const [description, setDescription] = useState(event?.description||'');
+ const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
+ const [saving, setSaving] = useState(false);
+
+ // Overlay state
+ const [showStartDate, setShowStartDate] = useState(false);
+ const [showEndDate, setShowEndDate] = useState(false);
+ const [showRecurrence, setShowRecurrence] = useState(false);
+ const [showGroups, setShowGroups] = useState(false);
+
+ // Auto-set typeId to default event type
+ useEffect(() => {
+ if(!event && typeId==='' && eventTypes.length>0) {
+ const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
+ if(def) setTypeId(String(def.id));
+ }
+ }, [eventTypes]);
+
+ // When start date changes, match end date
+ useEffect(() => { if(!event) setEd(sd); }, [sd]);
+
+ // When type or start time changes, auto-set end time
+ useEffect(() => {
+ if(!sd||!st) return;
+ const typ = eventTypes.find(t=>t.id===Number(typeId));
+ const dur = typ?.default_duration_hrs||1;
+ const start = buildISO(sd,st);
+ if(start && !event) { setEd(toDateIn(addHours(start,dur))); setEt(toTimeIn(addHours(start,dur))); }
+ }, [typeId, st]);
+
+ const handle = async () => {
+ if(!title.trim()) return toast('Title required','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, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], 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); }
+ };
+
+ const currentType = eventTypes.find(t=>t.id===Number(typeId));
+
+ const Row = ({ icon, label, children, onPress, value, border=true }) => (
+
+
{icon}
+
+ {label &&
{label}
}
+ {children}
+
+
+ );
+
+ return (
+
+ {/* Header */}
+
+
+ {event ? 'Edit Event' : 'New Event'}
+
+
+
+
+ {/* Title */}
+
+ setTitle(e.target.value)} placeholder="Add title" style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
+
+
+ {/* Event Type */}
+
} label="Event Type">
+
+
+
+ {/* All-day toggle */}
+
+
+ {/* Start date/time */}
+
setShowStartDate(true)} style={{ display:'flex',alignItems:'center',padding:'12px 20px 6px 56px',cursor:'pointer' }}>
+ {fmtDateDisplay(sd)}
+ {!allDay && {fmtTimeDisplay(st)}}
+
+
+ {/* End date/time */}
+
setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}>
+ {fmtDateDisplay(ed)}
+ {!allDay && (
+
+ )}
+
+
+ {/* Start time picker (show only if !allDay and user didn't tap date) */}
+ {!allDay && (
+
+ Start
+
+
+ )}
+
+ {/* Recurrence */}
+
} onPress={()=>setShowRecurrence(true)}>
+ {recurrenceLabel(recRule)}
+
+
+ {/* Track Availability */}
+
+
+
Track Availability
+
+
+
+ {/* Groups */}
+
+
setShowGroups(!showGroups)} style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
+
+
{groups.size>0 ? `${groups.size} group${groups.size!==1?'s':''} selected` : 'Add Groups'}
+
+
+ {showGroups && userGroups.map(g=>(
+
+ ))}
+
+
+ {/* Private Event */}
+
+
+ {/* Location */}
+
}>
+ setLocation(e.target.value)} placeholder="Add location" style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/>
+
+
+ {/* Description */}
+
} border={false}>
+
+
+ {/* Delete */}
+ {event && isToolManager && (
+
+
+
+ )}
+
+
+ {/* Overlays */}
+ {showStartDate &&
{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
+ {showEndDate && {setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
+ {showRecurrence && {setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
+
+ );
+}
diff --git a/frontend/src/components/MobileGroupManager.jsx b/frontend/src/components/MobileGroupManager.jsx
new file mode 100644
index 0000000..2f3b929
--- /dev/null
+++ b/frontend/src/components/MobileGroupManager.jsx
@@ -0,0 +1,131 @@
+import { useState, useEffect } from 'react';
+import { api } from '../utils/api.js';
+import { useToast } from '../contexts/ToastContext.jsx';
+import Avatar from './Avatar.jsx';
+
+export default function MobileGroupManager({ onClose }) {
+ const toast = useToast();
+ const [groups, setGroups] = useState([]);
+ const [allUsers, setAllUsers] = useState([]);
+ const [expanded, setExpanded] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [screen, setScreen] = useState('list'); // list | members
+ const [activeGroup, setActiveGroup] = useState(null);
+
+ const load = async () => {
+ try {
+ const [ug, us] = await Promise.all([api.getUserGroups(), api.getUsers()]);
+ setGroups(ug.groups || []);
+ setAllUsers(us.users || []);
+ } catch(e) { toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ };
+
+ useEffect(() => { load(); }, []);
+
+ const createGroup = async () => {
+ if(!newName.trim()) return;
+ setSaving(true);
+ try {
+ await api.createUserGroup({ name: newName.trim() });
+ setNewName(''); setCreating(false); load();
+ } catch(e) { toast(e.message,'error'); }
+ finally { setSaving(false); }
+ };
+
+ const deleteGroup = async (g) => {
+ if(!confirm(`Delete "${g.name}"?`)) return;
+ try { await api.deleteUserGroup(g.id); load(); } catch(e) { toast(e.message,'error'); }
+ };
+
+ const toggleMember = async (groupId, userId, isMember) => {
+ try {
+ if(isMember) await api.removeFromUserGroup(groupId, userId);
+ else await api.addToUserGroup(groupId, userId);
+ // Reload group members
+ const ug = await api.getUserGroups();
+ setGroups(ug.groups || []);
+ if(activeGroup) setActiveGroup((ug.groups||[]).find(g=>g.id===activeGroup.id)||null);
+ } catch(e) { toast(e.message,'error'); }
+ };
+
+ if(loading) return (
+ Loading…
+ );
+
+ // Members screen
+ if(screen==='members' && activeGroup) {
+ const memberIds = new Set((activeGroup.members||[]).map(m=>m.id||m.user_id));
+ return (
+
+
+
+
{activeGroup.name}
+
{memberIds.size} member{memberIds.size!==1?'s':''}
+
+
+
All Users
+ {allUsers.map(u => {
+ const isMember = memberIds.has(u.id);
+ return (
+
+
+
+
{u.display_name||u.name}
+
{u.role}
+
+
+
+ );
+ })}
+
+
+ );
+ }
+
+ // Group list screen
+ return (
+
+
+
+
Group Manager
+
+
+
+ {creating && (
+
+ setNewName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createGroup()} placeholder="Group name…" style={{ flex:1,padding:'8px 12px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:15 }}/>
+
+
+
+ )}
+
+
+ {groups.length===0 &&
No groups yet. Tap + to create one.
}
+ {groups.map(g => (
+
+
{setActiveGroup(g);setScreen('members');}}>
+
+ {g.name.substring(0,2).toUpperCase()}
+
+
+
{g.name}
+
{(g.members||[]).length} member{(g.members||[]).length!==1?'s':''}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx
index 2a81dd9..cec09cd 100644
--- a/frontend/src/components/NavDrawer.jsx
+++ b/frontend/src/components/NavDrawer.jsx
@@ -78,7 +78,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
<>
Tools
{item(NAV_ICON.users, 'User Manager', onUsers)}
- {features.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
+ {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
{features.scheduleManager && item(
NAV_ICON.schedules,
'Schedule Manager',
diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx
index 8239f7a..0917750 100644
--- a/frontend/src/components/SchedulePage.jsx
+++ b/frontend/src/components/SchedulePage.jsx
@@ -4,6 +4,8 @@ import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx';
import UserFooter from './UserFooter.jsx';
+import MobileEventForm from './MobileEventForm.jsx';
+import MobileGroupManager from './MobileGroupManager.jsx';
// ── Utilities ─────────────────────────────────────────────────────────────────
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
@@ -164,6 +166,89 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
);
}
+// ── Recurrence helpers ────────────────────────────────────────────────────────
+const FREQ_OPTIONS = [
+ { value: '', label: 'Does not repeat' },
+ { value: 'daily', label: 'Every day' },
+ { value: 'weekly', label: 'Every week' },
+ { value: 'monthly', label: 'Every month' },
+ { value: 'yearly', label: 'Every year' },
+ { value: 'custom', label: 'Custom…' },
+];
+const DAY_PILLS = ['S','M','T','W','T','F','S'];
+const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
+
+function recurrenceLabel(rule) {
+ if (!rule || !rule.freq) return 'Does not repeat';
+ const opt = FREQ_OPTIONS.find(o => o.value === rule.freq);
+ if (rule.freq !== 'custom') return opt?.label || rule.freq;
+ // Custom summary
+ const unit = rule.interval === 1 ? rule.unit : `${rule.interval} ${rule.unit}s`;
+ return `Every ${unit}`;
+}
+
+// Desktop recurrence selector — shown inline in the form
+function RecurrenceSelector({ value, onChange }) {
+ // value: { freq, interval, unit, byDay, ends, endDate, endCount } or null
+ const [showCustom, setShowCustom] = useState(false);
+ const rule = value || {};
+
+ const handleFreqChange = (freq) => {
+ if (freq === '') { onChange(null); return; }
+ if (freq === 'custom') { setShowCustom(true); onChange({ freq:'custom', interval:1, unit:'week', byDay:[], ends:'never', endDate:'', endCount:13 }); return; }
+ setShowCustom(false);
+ onChange({ freq });
+ };
+
+ return (
+
+
+ {(rule.freq==='custom') && (
+
+ )}
+
+ );
+}
+
+function CustomRecurrenceFields({ rule, onChange }) {
+ const upd = (k,v) => onChange({...rule,[k]:v});
+ return (
+
+
+ Every
+ upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/>
+
+
+ {(rule.unit||'week')==='week' && (
+
+
Repeats on
+
+ {DAY_PILLS.map((d,i)=>{
+ const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key);
+ return ;
+ })}
+
+
+ )}
+
+
Ends
+ {[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>(
+
+ ))}
+
+
+ );
+}
+
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
function FormRow({ label, children, required }) {
return (
@@ -195,6 +280,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [saving,setSaving]=useState(false);
const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes);
+ const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
// Also initialise typeId to the default event type for new events
useEffect(()=>{
@@ -262,7 +348,7 @@ 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]};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};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);}
};
return (
@@ -308,10 +394,11 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
-
+
@@ -421,6 +508,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}
+ {event.recurrence_rule?.freq&&(
+
+
+
{recurrenceLabel(event.recurrence_rule)}
+
+ )}
{event.location&&}
{event.description&&{event.description}
}
{(event.user_groups||[]).length>0&&{event.user_groups.map(g=>g.name).join(', ')}
}
@@ -751,6 +844,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
+ const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
const createRef = useRef(null);
const load = useCallback(() => {
@@ -918,13 +1012,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
{panel === 'calendar' && view === 'week' && }
{panel === 'calendar' && view === 'month' && {setSelDate(d);setView('schedule');}}/>}
- {panel === 'eventForm' && isToolManager && (
+ {panel === 'eventForm' && isToolManager && !isMobile && (
{editingEvent?'Edit Event':'New Event'}
{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
)}
+ {panel === 'eventForm' && isToolManager && isMobile && (
+ {setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
+ )}
{panel === 'eventTypes' && isToolManager && (
@@ -946,6 +1044,22 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
+ {/* Mobile Group Manager */}
+ {isMobile && mobilePanel === 'groupManager' && (
+
+ setMobilePanel(null)}/>
+
+ )}
+
+ {/* Mobile FAB for creating events */}
+ {isMobile && isToolManager && panel === 'calendar' && (
+
+ )}
+
{/* Event detail modal */}
{detailEvent && (
{ setDrawerOpen(false); setPage('chat'); }}
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
- onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
+ onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
@@ -363,6 +364,11 @@ export default function Chat() {
{modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && setModal(null)} />}
{modal === 'groupmanager' && setModal(null)} />}
+ {modal === 'mobilegroupmanager' && (
+
+ setModal(null)}/>
+
+ )}
{modal === 'about' && setModal(null)} />}
{modal === 'help' && setModal(null)} dismissed={helpDismissed} />}
@@ -387,7 +393,7 @@ export default function Chat() {
onUsers={() => setModal('users')}
onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')}
- onGroupManager={() => setModal('groupmanager')}
+ onGroupManager={() => isMobile ? setModal('mobilegroupmanager') : setModal('groupmanager')}
features={features}
onGroupsUpdated={loadGroups}
isMobile={isMobile}
@@ -414,7 +420,7 @@ export default function Chat() {
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
- onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
+ onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
@@ -427,6 +433,11 @@ export default function Chat() {
{modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && setModal(null)} />}
{modal === 'groupmanager' && setModal(null)} />}
+ {modal === 'mobilegroupmanager' && (
+
+ setModal(null)}/>
+
+ )}
{modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && setModal(null)} />}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index b284e2b..0ae796d 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -114,7 +114,7 @@ export const api = {
return req('GET', `/schedule${qs ? '?' + qs : ''}`);
},
getEvent: (id) => req('GET', `/schedule/${id}`),
- createEvent: (body) => req('POST', '/schedule', body),
+ createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id) => req('DELETE', `/schedule/${id}`),
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),