mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
535c06bb3f
- Split `media:read` into `geo:read` and `weather:read` scopes - Add dedicated `atlas:read/write` scopes (previously under `places`) - Add dedicated `todos:read/write` scopes (previously under `collab`) - Rate limiting now keyed by userId+clientId instead of userId alone - Bind MCP sessions to the OAuth client that created them - Log MCP tool calls to audit log with clientId - Invalidate all MCP sessions on addon state change - Reduce session sweep interval from 10min to 1min - Update all translations with new scope labels
201 lines
9.3 KiB
TypeScript
201 lines
9.3 KiB
TypeScript
import React, { useMemo } from 'react'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
|
|
|
function formatTime(timeStr, is12h) {
|
|
if (!timeStr) return ''
|
|
const [h, m] = timeStr.split(':').map(Number)
|
|
if (is12h) {
|
|
const period = h >= 12 ? 'PM' : 'AM'
|
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
|
}
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
}
|
|
|
|
function formatDayLabel(date, t, locale) {
|
|
const now = new Date()
|
|
const nowDate = now.toISOString().split('T')[0]
|
|
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
|
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
|
|
|
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
|
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
|
|
|
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
|
}
|
|
|
|
interface TripMember {
|
|
id: number
|
|
username: string
|
|
avatar_url?: string | null
|
|
}
|
|
|
|
interface WhatsNextWidgetProps {
|
|
tripMembers?: TripMember[]
|
|
}
|
|
|
|
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
|
|
const { days, assignments } = useTripStore()
|
|
const { t, locale } = useTranslation()
|
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
|
|
|
const upcoming = useMemo(() => {
|
|
const now = new Date()
|
|
const nowDate = now.toISOString().split('T')[0]
|
|
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
|
const items = []
|
|
|
|
for (const day of (days || [])) {
|
|
if (!day.date) continue
|
|
const dayAssignments = assignments[String(day.id)] || []
|
|
for (const a of dayAssignments) {
|
|
if (!a.place) continue
|
|
// Include: today (future times) + all future days
|
|
const isFutureDay = day.date > nowDate
|
|
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
|
|
if (isFutureDay || isTodayFuture) {
|
|
items.push({
|
|
id: a.id,
|
|
name: a.place.name,
|
|
time: a.place.place_time,
|
|
endTime: a.place.end_time,
|
|
date: day.date,
|
|
dayTitle: day.title,
|
|
category: a.place.category,
|
|
participants: (a.participants && a.participants.length > 0)
|
|
? a.participants
|
|
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
|
|
address: a.place.address,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
items.sort((a, b) => {
|
|
const da = a.date + (a.time || '99:99')
|
|
const db = b.date + (b.time || '99:99')
|
|
return da.localeCompare(db)
|
|
})
|
|
|
|
return items.slice(0, 8)
|
|
}, [days, assignments, tripMembers])
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
{/* Header */}
|
|
<div style={{
|
|
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
|
}}>
|
|
<Sparkles size={14} color="var(--text-faint)" />
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
|
{t('collab.whatsNext.title') || "What's Next"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
|
{upcoming.length === 0 ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
|
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{upcoming.map((item, idx) => {
|
|
const prevItem = upcoming[idx - 1]
|
|
const showDayHeader = !prevItem || prevItem.date !== item.date
|
|
|
|
return (
|
|
<React.Fragment key={item.id}>
|
|
{showDayHeader && (
|
|
<div style={{
|
|
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
|
textTransform: 'uppercase', letterSpacing: 0.5,
|
|
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
|
}}>
|
|
{formatDayLabel(item.date, t, locale)}
|
|
{item.dayTitle ? ` — ${item.dayTitle}` : ''}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{
|
|
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
|
|
background: 'var(--bg-secondary)', transition: 'background 0.1s',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
|
>
|
|
{/* Time column */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
|
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
|
</span>
|
|
{item.endTime && (
|
|
<>
|
|
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
|
{t('collab.whatsNext.until') || 'bis'}
|
|
</span>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
|
{formatTime(item.endTime, is12h)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
|
|
|
|
{/* Details */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.name}
|
|
</div>
|
|
{item.address && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
|
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
|
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.address}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Participants */}
|
|
{item.participants.length > 0 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
|
|
{item.participants.map(p => (
|
|
<div key={p.user_id} style={{
|
|
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
|
|
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
|
|
}}>
|
|
<div style={{
|
|
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
|
overflow: 'hidden', flexShrink: 0,
|
|
}}>
|
|
{p.avatar
|
|
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
: p.username?.[0]?.toUpperCase()
|
|
}
|
|
</div>
|
|
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|