mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user