fix(mobile): local dates, revoked object URLs, and writes that said nothing

Four screens derived "today" from toISOString(), which is the UTC calendar
day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went
overdue eight hours early in Tokyo, and a new journal entry got the wrong day
either side of midnight. All four take the viewer's own calendar day now,
through the helper the planner already had.

Three sheets built preview object URLs in render and never revoked them, so
scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes
from inside a setState updater, which StrictMode double-invokes.

And several writes failed in silence: the notification toggle, the collab
feature switch rolling back to a stale snapshot, the appearance change whose
debounced write was dropped on unmount so it reverted on the next reload. The
backup-code print page skipped the escaping its desktop twin does, the
clipboard copy threw on a non-HTTPS install, and reservation metadata was
JSON.parsed unguarded in render, which takes the whole route down.
This commit is contained in:
Maurice
2026-08-23 10:08:23 +02:00
committed by Maurice
parent 19745b7b0d
commit b3d8bf08f1
14 changed files with 98 additions and 33 deletions
+6 -4
View File
@@ -230,12 +230,14 @@ export default function MAdmin() {
}}
collabFeatures={collabFeatures}
onToggleCollabFeature={async (key: string) => {
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
setCollabFeatures(next)
const previous = collabFeatures[key]
setCollabFeatures({ ...collabFeatures, [key]: !previous })
try {
await adminApi.updateCollabFeatures({ [key]: next[key] })
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
setCollabFeatures(collabFeatures)
// Only this key rolls back — a slower request must not undo a toggle
// the admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}}
/>
@@ -89,6 +89,13 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
setError('')
}, [trip, open])
// The local file preview is a blob url; release it once a new cover replaces it
// or the sheet goes away. Server and Unsplash urls are left alone.
useEffect(() => {
if (!coverPreview?.startsWith('blob:')) return
return () => { URL.revokeObjectURL(coverPreview) }
}, [coverPreview])
// Moving the start keeps the trip length (same rule as TripFormModal).
const changeStart = (value: string) => {
if (value && endDate && startDate && endDate >= startDate) {
@@ -135,7 +135,9 @@ export default function MJourneyDetail() {
const [uploading, setUploading] = useState(false)
useEffect(() => {
(async () => {
let active = true
const ac = new AbortController()
;(async () => {
try {
const addonsData = await addonsApi.enabled()
const enabled = (addonsData.addons || []).filter(
@@ -144,13 +146,14 @@ export default function MJourneyDetail() {
const connected: { id: string; name: string }[] = []
for (const p of enabled) {
try {
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include', signal: ac.signal })
if (res.ok && (await res.json()).connected) connected.push({ id: p.id, name: p.name })
} catch { /* provider stays hidden */ }
}
setAvailableProviders(connected)
if (active) setAvailableProviders(connected)
} catch { /* no providers */ }
})()
return () => { active = false; ac.abort() }
}, [])
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -101,6 +101,11 @@ export default function MJourneyEntrySheet({
[addons],
)
// Minting the preview inside the tile markup would hand out a fresh blob URL on
// every keystroke in the story field, and nothing would ever release them.
const pendingPreviews = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
useEffect(() => () => { pendingPreviews.forEach(url => URL.revokeObjectURL(url)) }, [pendingPreviews])
useEffect(() => {
if (readOnly) return
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
@@ -536,13 +541,13 @@ export default function MJourneyEntrySheet({
<button
type="button"
onClick={() => {
setPhotos(prev => {
const next = [...prev]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
return next
})
// The PATCHes stay outside the updater — StrictMode invokes it
// twice in dev and would send the whole batch a second time.
const next = [...photos]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
setPhotos(next)
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
}}
className="absolute bottom-[3px] left-[3px] rounded-full bg-black/60 px-[6px] py-[1px] font-geist text-[0.5rem] font-bold text-white"
>
@@ -568,9 +573,9 @@ export default function MJourneyEntrySheet({
)}
</div>
))}
{pendingFiles.map((f, i) => (
{pendingFiles.map((_, i) => (
<div key={`pending-${i}`} className="relative h-16 w-16 overflow-hidden rounded-[13px]">
<img src={URL.createObjectURL(f)} alt="" className="h-full w-full object-cover" />
<img src={pendingPreviews[i]} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
@@ -131,6 +131,27 @@ export default function MJourneySettingsSheet({
}
}
const copyShareUrl = async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(shareUrl)
} else {
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
const ta = document.createElement('textarea')
ta.value = shareUrl
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.focus()
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* ignore */ }
}
const toggleSharePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!shareLink) return
const updated = { ...shareLink, [key]: !shareLink[key] }
@@ -333,11 +354,7 @@ export default function MJourneySettingsSheet({
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{shareUrl}</span>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
onClick={copyShareUrl}
className="flex-none rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
@@ -18,6 +18,7 @@ import {
} from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router'
import { startRegistration } from '@simplewebauthn/browser'
import { escapeHtml } from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { useAuthStore } from '../../../store/authStore'
import { useToast } from '../../../components/shared/Toast'
@@ -136,7 +137,7 @@ export default function MSettingsAccount() {
const printBackupCodes = () => {
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`
const w = window.open('', '_blank', 'width=900,height=700')
if (!w) return
w.document.open()
@@ -85,6 +85,9 @@ export default function MSettingsAppearance() {
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
const persistTimer = useRef<number | undefined>(undefined)
// What the pending timer would have written, so leaving the screen inside the
// debounce window still saves instead of silently dropping the change.
const pendingWrite = useRef<AppearanceConfig | null>(null)
// Re-sync when settings change elsewhere (server reconcile / another tab).
useEffect(() => {
@@ -92,8 +95,11 @@ export default function MSettingsAppearance() {
}, [settings.appearance])
useEffect(() => () => {
if (persistTimer.current) window.clearTimeout(persistTimer.current)
}, [])
if (!persistTimer.current) return
window.clearTimeout(persistTimer.current)
// The component is gone, so a failure has nowhere to be shown.
if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {})
}, [updateSetting])
const isDark =
settings.dark_mode === true ||
@@ -106,7 +112,9 @@ export default function MSettingsAppearance() {
setCfg(next)
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
if (persistTimer.current) window.clearTimeout(persistTimer.current)
pendingWrite.current = next
persistTimer.current = window.setTimeout(() => {
pendingWrite.current = null
updateSetting('appearance', next).catch((e: unknown) =>
toast.error(e instanceof Error ? e.message : t('common.error')),
)
@@ -127,7 +127,13 @@ export default function MSettingsNotifications() {
try {
await notificationsApi.updatePreferences(updated)
} catch {
setMatrix((m) => (m ? { ...m, preferences: matrix.preferences } : m))
// Only this cell rolls back — restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix((m) => (m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
} : m))
toast.error(t('common.error'))
} finally {
setSaving(false)
}
@@ -417,8 +417,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: {
/** Icon-coded like the demo, colour-tinted per the audit (green in / red out). */
function HotelChipIcon({ variant }: { variant: 'checkout' | 'checkin' | 'stay' }) {
if (variant === 'checkout') return <LogOut size={12} strokeWidth={2.2} className="text-[#D6273B]" />
if (variant === 'checkin') return <LogIn size={12} strokeWidth={2.2} className="text-[#3BA55C]" />
if (variant === 'checkout') return <LogOut size={12} strokeWidth={2.2} className="text-[color:var(--m-st-danger)]" />
if (variant === 'checkin') return <LogIn size={12} strokeWidth={2.2} className="text-[color:var(--m-st-confirmed)]" />
return <BedDouble size={12} strokeWidth={2.2} className="text-m-muted" />
}
@@ -12,6 +12,7 @@ import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney, localizeAmountInput, cleanAmount } from '../../../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { localToday } from '../../../../components/Planner/today'
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
@@ -72,7 +73,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
const [catOpen, setCatOpen] = useState(false)
const [note, setNote] = useState(() => readUserNote(editing))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [day, setDay] = useState(editing?.expense_date || localToday())
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
if (prefill?.amount != null) return String(prefill.amount)
@@ -296,6 +297,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<span
style={{
width: size, height: size, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
// theme-lint-disable — white initial on the member's tint, and the glyph scales with the avatar
color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0, opacity: dim ? 0.45 : 1,
}}
>
@@ -608,7 +610,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
) : (
<span className={`font-semibold ${customBalanced ? 'text-[#16a34a]' : 'text-[color:var(--m-st-danger)]'}`}>
<span className={`font-semibold ${customBalanced ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}>
{customBalanced
? t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })
: `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`}
@@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
import { resolveDayId } from '../../../../utils/formatters'
import { parseReservationMetadata } from '../../../../utils/flightLegs'
import { typeToCostCategory } from '@trek/shared'
import CustomSelect from '../../../../components/shared/CustomSelect'
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
@@ -86,7 +87,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const res = editingReservation
if (res) {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const meta = parseReservationMetadata(res)
const rawEnd = res.reservation_end_time || ''
let endDate = '', endTime = rawEnd
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
@@ -37,8 +37,14 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
const fileMimeType = file?.mime_type
useEffect(() => {
// Swiping is faster than the resource-token round trip, so a stale token must
// not overwrite the file the user is looking at now.
let cancelled = false
setImgSrc('')
if (fileUrl && !isVideo(fileMimeType)) getAuthUrl(fileUrl, 'download').then(setImgSrc)
if (fileUrl && !isVideo(fileMimeType)) {
getAuthUrl(fileUrl, 'download').then(url => { if (!cancelled) setImgSrc(url) })
}
return () => { cancelled = true }
}, [fileUrl, fileMimeType])
const hasPrev = index > 0
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../../../store/authStore'
import { useTranslation } from '../../../../i18n'
import { avatarSrc } from '../../../../utils/avatarSrc'
import { formatDate } from '../../../../utils/formatters'
import { localToday } from '../../../../components/Planner/today'
import type { TodoItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { TabScroller } from './tabChrome'
@@ -39,7 +40,9 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [creatingTask, setCreatingTask] = useState(false)
const today = useMemo(() => new Date().toISOString().slice(0, 10), [])
// The user's calendar day, not UTC's — an overdue task must turn overdue at
// the traveller's midnight, not eight hours either side of it.
const today = useMemo(() => localToday(), [])
const categories = useMemo(() => todoCategories(items), [items])
const counts = todoCounts(items, currentUserId, today)
const rows = useMemo(
@@ -215,8 +215,12 @@ export interface CsvBuildOptions {
/** Ports `CostsPanel.tsx`'s `handleExportCsv` row-building 1:1; the Blob/download is a DOM concern left to the caller. */
export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { filename: string; content: string } {
const sep = ';'
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
// and Sheets, and the name/note columns are free text any trip member can write.
// Mirrors the same guard in `CostsPanel.tsx`'s exporter.
const esc = (v: unknown) => {
const s = String(v ?? '')
let s = String(v ?? '')
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s
}
const fmtDate = (iso: string) => {