diff --git a/.env.example b/.env.example index b99f48d..2412ac4 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.85 +JAMA_VERSION=0.9.86 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index 7fe4495..e7b4b42 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.85", + "version": "0.9.86", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 6c5efeb..2c36509 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.85}" +VERSION="${1:-0.9.86}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 896e4db..bfeeafe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.85", + "version": "0.9.86", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 22e1622..62ffe36 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -714,6 +714,98 @@ function BulkImportPanel({ onImported, onCancel }) { // ── Calendar Views ──────────────────────────────────────────────────────────── // Parse keyword string into array of terms. // Quoted phrases ("foo bar") count as one term; space-separated words are individual OR terms. +// ── Recurring event expansion ───────────────────────────────────────────────── +// Generates virtual occurrences from a recurring event's rule. +// Returns array of cloned event objects with adjusted start_at / end_at. +function expandRecurringEvent(ev, rangeStart, rangeEnd) { + const rule = ev.recurrence_rule; + if (!rule || !rule.freq) return [ev]; + + const origStart = new Date(ev.start_at); + const origEnd = new Date(ev.end_at); + const durMs = origEnd - origStart; + + const occurrences = []; + let cur = new Date(origStart); + let count = 0; + const maxOccurrences = 500; // safety cap + + // Step size based on freq/unit + const freq = rule.freq === 'custom' ? rule.unit : rule.freq.replace('ly','').replace('dai','day').replace('week','week').replace('month','month').replace('year','year'); + const interval = rule.interval || 1; + + const step = (d) => { + const n = new Date(d); + if (freq === 'day' || rule.freq === 'daily') n.setDate(n.getDate() + interval); + else if (freq === 'week' || rule.freq === 'weekly') n.setDate(n.getDate() + 7 * interval); + else if (freq === 'month' || rule.freq === 'monthly') n.setMonth(n.getMonth() + interval); + else if (freq === 'year' || rule.freq === 'yearly') n.setFullYear(n.getFullYear() + interval); + else n.setDate(n.getDate() + 7); // fallback weekly + return n; + }; + + // For weekly with byDay, generate per-day occurrences + const byDay = rule.byDay && rule.byDay.length > 0 ? rule.byDay : null; + const DAY_MAP = {SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6}; + + // Determine end condition + const endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null; + const endCount = rule.ends === 'after' ? (rule.endCount || 13) : null; + + // Start from original and step forward + while (count < maxOccurrences) { + // Check end conditions + if (endDate && cur > endDate) break; + if (endCount && occurrences.length >= endCount) break; + if (cur > rangeEnd) break; + + if (byDay && (rule.freq === 'weekly' || freq === 'week')) { + // Emit one occurrence per byDay in this week + const weekStart = new Date(cur); + weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week + for (const dayKey of byDay) { + const dayNum = DAY_MAP[dayKey]; + const occ = new Date(weekStart); + occ.setDate(weekStart.getDate() + dayNum); + occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds()); + if (occ >= rangeStart && occ <= rangeEnd) { + if (!endDate || occ <= endDate) { + const occEnd = new Date(occ.getTime() + durMs); + occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true}); + } + } + } + cur = step(cur); + } else { + if (cur >= rangeStart && cur <= rangeEnd) { + const occEnd = new Date(cur.getTime() + durMs); + occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at}); + } + cur = step(cur); + } + count++; + } + + // Always include the original even if before rangeStart + return occurrences.length > 0 ? occurrences : [ev]; +} + +// Expand all recurring events in a list within a date range +function expandEvents(events, rangeStart, rangeEnd) { + const result = []; + for (const ev of events) { + if (ev.recurrence_rule?.freq) { + const expanded = expandRecurringEvent(ev, rangeStart, rangeEnd); + result.push(...expanded); + } else { + result.push(ev); + } + } + // Sort by start_at + result.sort((a,b) => new Date(a.start_at) - new Date(b.start_at)); + return result; +} + function parseKeywords(raw) { const terms = []; const re = /"([^"]+)"|(\S+)/g; @@ -727,6 +819,9 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter const today=new Date(); today.setHours(0,0,0,0); const terms=parseKeywords(filterKeyword); const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability; + // Expand recurring events over a wide range (2 years forward) + const farFuture = new Date(today); farFuture.setFullYear(farFuture.getFullYear()+2); + const expandedEvents = expandEvents(events, new Date(y,m,1), farFuture); const now = new Date(); // exact now for end-time comparison const isCurrentMonth = y === today.getFullYear() && m === today.getMonth(); // No filters: show from today (if current month) or start of month (future months) to end of month. @@ -734,7 +829,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter // Filters active: today + all future events. const from = hasFilters ? today : (isCurrentMonth ? today : new Date(y,m,1)); const to = hasFilters ? new Date(9999,11,31) : new Date(y,m+1,0,23,59,59); - const filtered=events.filter(e=>{ + const filtered=expandedEvents.filter(e=>{ const s=new Date(e.start_at); if(sto) return false; if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false; @@ -857,7 +952,10 @@ function layoutEvents(evs) { return result; } -function DayView({ events, selectedDate, onSelect, onSwipe }) { +function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) { + const dayStart = new Date(selectedDate); dayStart.setHours(0,0,0,0); + const dayEnd = new Date(selectedDate); dayEnd.setHours(23,59,59,999); + const events = expandEvents(rawEvents, dayStart, dayEnd); 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); @@ -913,7 +1011,10 @@ function DayView({ events, selectedDate, onSelect, onSwipe }) { ); } -function WeekView({ events, selectedDate, onSelect }) { +function WeekView({ events: rawEvents, selectedDate, onSelect }) { + const ws = weekStart(selectedDate); + const we = new Date(ws); we.setDate(we.getDate()+6); we.setHours(23,59,59,999); + const events = expandEvents(rawEvents, ws, we); 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:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date(); const scrollRef = useRef(null); @@ -983,8 +1084,10 @@ function WeekView({ events, selectedDate, onSelect }) { const MONTH_CELL_H = 90; // fixed cell height in px -function MonthView({ events, selectedDate, onSelect, onSelectDay }) { +function MonthView({ events: rawEvents, selectedDate, onSelect, onSelectDay }) { const y=selectedDate.getFullYear(), m=selectedDate.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date(); + const monthStart = new Date(y,m,1), monthEnd = new Date(y,m+1,0,23,59,59,999); + const events = expandEvents(rawEvents, monthStart, monthEnd); const cells=[]; for(let i=0;i +
{panel === 'calendar' && view === 'schedule' &&
} {panel === 'calendar' && view === 'day' && { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>} {panel === 'calendar' && view === 'week' && } - {panel === 'calendar' && view === 'month' && {setSelDate(d);setView('schedule');}}/>} + {panel === 'calendar' && view === 'month' && {setSelDate(d);setView('day');}}/>} {panel === 'eventForm' && isToolManager && !isMobile && (