diff --git a/.env.example b/.env.example index 9767565..8f9c02d 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.67 +JAMA_VERSION=0.9.68 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index e12722d..aa1eb60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.67", + "version": "0.9.68", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index b752455..81fb7e8 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.67}" +VERSION="${1:-0.9.68}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 889e525..c3e5c8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.67", + "version": "0.9.68", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 3a0c0c0..77cedc1 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -98,41 +98,81 @@ function MiniCalendar({ selected, onChange, eventDates=new Set() }) { ); } -// ── Mobile Date Picker (accordion month view) ───────────────────────────────── -function MobileDatePicker({ selected, onChange, eventDates=new Set() }) { +// ── Mobile Filter Bar (Schedule view: keyword+type filters with month nav; Day view: calendar accordion) ── +function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filterKeyword, onFilterKeyword, filterTypeId, onFilterTypeId, eventDates=new Set() }) { + // Day view: keep accordion calendar const [open, setOpen] = useState(false); - const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; }); - const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m); - const cells=[]; for(let i=0;i + + {open && ( +
+
+ + +
+
+ {DAYS.map(d=>
{d[0]}
)} + {cells.map((d,i)=>{ + if(!d) return
; + const date=new Date(y,m,d), isSel=sameDay(date,selected), isToday=sameDay(date,today); + const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; + return ( +
{const nd=new Date(y,m,d);onMonthChange(0,nd);setOpen(false);}} style={{textAlign:'center',padding:'5px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,position:'relative'}}> + {d} + {eventDates.has(key)&&!isSel&&} +
+ ); + })} +
+
+ )} +
+ ); + } + + // Schedule view: filter bar with month nav + keyword + event type + const hasFilters = filterKeyword || filterTypeId; return ( -
- - {open && ( -
-
- - -
-
- {DAYS.map(d=>
{d[0]}
)} - {cells.map((d,i)=>{ - if(!d) return
; - const date=new Date(y,m,d), isSel=selected&&sameDay(date,new Date(selected)), isToday=sameDay(date,today); - const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; - return ( -
{onChange(date);setOpen(false);}} style={{textAlign:'center',padding:'5px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday?700:400,position:'relative'}}> - {d} - {eventDates.has(key)&&!isSel&&} -
- ); - })} -
+
+ {/* Month nav row */} +
+ + {MONTHS[m]} {y} + +
+ {/* Filter inputs */} +
+
+ + onFilterKeyword(e.target.value)} + placeholder="Search events…" + style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}} + />
- )} + + {hasFilters && ( + + )} +
); } @@ -610,20 +650,39 @@ 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. +function parseKeywords(raw) { + const terms = []; + const re = /"([^"]+)"|(\S+)/g; + let match; + while((match = re.exec(raw)) !== null) terms.push((match[1]||match[2]).toLowerCase()); + return terms; +} + function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', isMobile=false }) { const y=selectedDate.getFullYear(), m=selectedDate.getMonth(); - const monthStart=new Date(y,m,1), monthEnd=new Date(y,m+1,0,23,59,59); - const kw=filterKeyword.toLowerCase().trim(); + const today=new Date(); today.setHours(0,0,0,0); + const terms=parseKeywords(filterKeyword); + const hasFilters = terms.length > 0 || !!filterTypeId; + // Always show from today forward (desktop and mobile). + // Desktop: when no filters, restrict to selected month for browsing context. + // Mobile: always from today forward regardless of filters. + // With any filter active: always today+future on both platforms. + const from = (hasFilters || isMobile) ? today : new Date(y,m,1); + const to = (hasFilters || isMobile) ? new Date(9999,11,31) : new Date(y,m+1,0,23,59,59); const filtered=events.filter(e=>{ const s=new Date(e.start_at); - if(smonthEnd) return false; + if(sto) return false; if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false; - if(kw && ![ - e.title||'', e.location||'', e.description||'' - ].some(f=>f.toLowerCase().includes(kw))) return false; + if(terms.length>0) { + const haystack=[e.title||'',e.location||'',e.description||''].join(' ').toLowerCase(); + if(!terms.some(t=>haystack.includes(t))) return false; + } return true; }); - if(!filtered.length) return
{kw||filterTypeId?'No events match your filters':'No events in'} {!kw&&!filterTypeId&&`${MONTHS[m]} ${y}`}
; + const emptyMsg = hasFilters ? 'No events match your filters' : isMobile ? 'No upcoming events' : `No events in ${MONTHS[m]} ${y}`; + if(!filtered.length) return
{emptyMsg}
; return <>{filtered.map(e=>{const s=new Date(e.start_at);const col=e.event_type?.colour||'#9ca3af'; // Desktop: original pre-v0.9.64 sizes. Mobile: compact sizes from v0.9.64 const rowPad=isMobile?'12px 14px':'14px 20px'; @@ -691,14 +750,24 @@ function layoutEvents(evs) { return result; } -function DayView({ events, selectedDate, onSelect }) { +function DayView({ events, selectedDate, onSelect, onSwipe }) { 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); + const touchRef = useRef({ x:0, y:0 }); 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`; + const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; }; + const handleTouchEnd = e => { + const dx = e.changedTouches[0].clientX - touchRef.current.x; + const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y); + // Require horizontal swipe > 60px, not too vertical, and not from left edge (< 30px = back gesture) + if(Math.abs(dx) > 60 && dy < 80 && touchRef.current.x > 30) { + onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day + } + }; return( -
+
{DAYS[selectedDate.getDay()]}
{selectedDate.getDate()}
@@ -740,10 +809,20 @@ 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:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date(); const scrollRef = useRef(null); + const touchRef = useRef({ x:0, y:0 }); 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`; + const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; }; + const handleTouchEnd = e => { + const dx = e.changedTouches[0].clientX - touchRef.current.x; + const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y); + // Require horizontal swipe > 60px, not too vertical, and not from left edge (< 30px = back gesture) + if(Math.abs(dx) > 60 && dy < 80 && touchRef.current.x > 30) { + onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day + } + }; return( -
+
{/* Day headers */}
@@ -947,10 +1026,10 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel {/* List view filters — only shown in Schedule list view */} {view==='schedule' && panel==='calendar' && (
-
Search
+
Search (today & future)
setFilterKeyword(e.target.value)} style={{ marginBottom:8, fontSize:13 }} @@ -1016,15 +1095,28 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
- {/* Mobile date picker */} - {isMobile && ( - {setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/> + {/* Mobile filter bar — Schedule view: filters + month nav; Day view: calendar accordion */} + {isMobile && panel === 'calendar' && ( + { + if(exactDate) { setSelDate(exactDate); } + else { const d=new Date(selDate); d.setMonth(d.getMonth()+dir); d.setDate(1); setSelDate(d); } + }} + /> )} {/* Calendar or panel content */}
{panel === 'calendar' && view === 'schedule' && } - {panel === 'calendar' && view === 'day' && } + {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');}}/>}