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 */} +
+ + All day + +
+ + {/* 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 */} +
+ + 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}> +