v0.9.68 new filter rules

This commit is contained in:
2026-03-18 10:55:07 -04:00
parent 3d3e8068db
commit efaec151c6
5 changed files with 143 additions and 51 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.9.67",
"version": "0.9.68",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.9.67}"
VERSION="${1:-0.9.68}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.67",
"version": "0.9.68",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -98,13 +98,16 @@ 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<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
const y=selected.getFullYear(), m=selected.getMonth();
const today=new Date();
if(view==='day') {
const first=new Date(y,m,1).getDay(), total=daysInMonth(y,m);
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
return (
<div style={{borderBottom:'1px solid var(--border)',background:'var(--surface)'}}>
<button onClick={()=>setOpen(v=>!v)} style={{display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'10px 16px',background:'none',border:'none',cursor:'pointer',fontSize:14,fontWeight:600,color:'var(--text-primary)'}}>
@@ -114,17 +117,17 @@ function MobileDatePicker({ selected, onChange, eventDates=new Set() }) {
{open && (
<div style={{padding:'8px 12px 12px',userSelect:'none'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}}></button>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(n);}}></button>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>onMonthChange(-1)}></button>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>onMonthChange(1)}></button>
</div>
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:2,fontSize:12}}>
{DAYS.map(d=><div key={d} style={{textAlign:'center',fontWeight:600,color:'var(--text-tertiary)',padding:'2px 0'}}>{d[0]}</div>)}
{cells.map((d,i)=>{
if(!d) return <div key={i}/>;
const date=new Date(y,m,d), isSel=selected&&sameDay(date,new Date(selected)), isToday=sameDay(date,today);
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 (
<div key={i} onClick={()=>{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'}}>
<div key={i} onClick={()=>{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&&<span style={{position:'absolute',bottom:2,left:'50%',transform:'translateX(-50%)',width:4,height:4,borderRadius:'50%',background:'var(--primary)',display:'block'}}/>}
</div>
@@ -135,6 +138,43 @@ function MobileDatePicker({ selected, onChange, eventDates=new Set() }) {
)}
</div>
);
}
// Schedule view: filter bar with month nav + keyword + event type
const hasFilters = filterKeyword || filterTypeId;
return (
<div style={{background:'var(--surface)',borderBottom:'1px solid var(--border)'}}>
{/* Month nav row */}
<div style={{display:'flex',alignItems:'center',padding:'8px 16px 0',gap:8}}>
<button onClick={()=>onMonthChange(-1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
<span style={{flex:1,textAlign:'center',fontSize:14,fontWeight:600}}>{MONTHS[m]} {y}</span>
<button onClick={()=>onMonthChange(1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
</div>
{/* Filter inputs */}
<div style={{padding:'8px 12px 10px',display:'flex',gap:8,alignItems:'center'}}>
<div style={{flex:1,position:'relative'}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
value={filterKeyword}
onChange={e=>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'}}
/>
</div>
<select
value={filterTypeId}
onChange={e=>onFilterTypeId(e.target.value)}
style={{padding:'7px 8px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,flexShrink:0,maxWidth:130}}
>
<option value="">All types</option>
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{hasFilters && (
<button onClick={()=>{onFilterKeyword('');onFilterTypeId('');}} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-tertiary)',fontSize:18,padding:'2px 4px',lineHeight:1,flexShrink:0}}></button>
)}
</div>
</div>
);
}
// ── Event Type Popup ──────────────────────────────────────────────────────────
@@ -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(s<monthStart||s>monthEnd) return false;
if(s<from||s>to) 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 <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{kw||filterTypeId?'No events match your filters':'No events in'} {!kw&&!filterTypeId&&`${MONTHS[m]} ${y}`}</div>;
const emptyMsg = hasFilters ? 'No events match your filters' : isMobile ? 'No upcoming events' : `No events in ${MONTHS[m]} ${y}`;
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
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(
<div style={{display:'flex',flexDirection:'column',height:'100%'}}>
<div style={{display:'flex',flexDirection:'column',height:'100%'}} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)',flexShrink:0}}>
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
</div>
@@ -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(
<div style={{display:'flex',flexDirection:'column',height:'100%'}}>
<div style={{display:'flex',flexDirection:'column',height:'100%'}} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
{/* Day headers */}
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',background:'var(--surface)',flexShrink:0}}>
<div/>
@@ -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' && (
<div style={{ padding:'0 16px 16px' }}>
<div className="section-label" style={{ marginBottom:8 }}>Search</div>
<div className="section-label" style={{ marginBottom:8 }}>Search (today &amp; future)</div>
<input
className="input"
placeholder="Keyword…"
placeholder={`Keyword… (space = OR, "phrase")`}
value={filterKeyword}
onChange={e=>setFilterKeyword(e.target.value)}
style={{ marginBottom:8, fontSize:13 }}
@@ -1016,15 +1095,28 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
</div>
</div>
{/* Mobile date picker */}
{isMobile && (
<MobileDatePicker selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
{/* Mobile filter bar — Schedule view: filters + month nav; Day view: calendar accordion */}
{isMobile && panel === 'calendar' && (
<MobileScheduleFilter
selected={selDate}
view={view}
eventTypes={eventTypes}
filterKeyword={filterKeyword}
onFilterKeyword={setFilterKeyword}
filterTypeId={filterTypeId}
onFilterTypeId={setFilterTypeId}
eventDates={eventDates}
onMonthChange={(dir, exactDate) => {
if(exactDate) { setSelDate(exactDate); }
else { const d=new Date(selDate); d.setMonth(d.getMonth()+dir); d.setDate(1); setSelDate(d); }
}}
/>
)}
{/* Calendar or panel content */}
<div style={{ flex:1, overflowY:'auto', overflowX: panel==='eventForm'?'auto':'hidden' }}>
{panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId} isMobile={isMobile}/>}
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail} onSwipe={isMobile ? dir => { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>}
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('schedule');}}/>}