Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
jubnl
2026-08-11 21:15:40 +02:00
130 changed files with 2289 additions and 337 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers'
import { hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -76,3 +76,59 @@ describe('rebalancePayers', () => {
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
})
})
// ── Notes vs. itemized receipts (#1658) ──────────────────────────────────────
describe('readTicketItems', () => {
const receipt = '{"items":[{"name":"Bread","price":"3.50","parts":[1,2]},{"name":"Wine","price":"12","parts":[2]}]}'
it('reads the receipt out of its own column', () => {
const items = readTicketItems({ ticket_json: receipt })
expect(items.map(i => i.name)).toEqual(['Bread', 'Wine'])
expect(items[0].participants).toEqual(new Set([1, 2]))
expect(items[1].price).toBe('12')
})
it('still reads a response cached before the column existed', () => {
const items = readTicketItems({ note: 'TICKETJSON:' + receipt })
expect(items).toHaveLength(2)
expect(items[1].participants).toEqual(new Set([2]))
})
it('prefers the column over the legacy note when both are present', () => {
const items = readTicketItems({ ticket_json: '{"items":[{"name":"Only","price":"1","parts":[]}]}', note: 'TICKETJSON:' + receipt })
expect(items.map(i => i.name)).toEqual(['Only'])
})
it('is empty for an expense with neither, and for malformed JSON', () => {
expect(readTicketItems({ note: 'just a note' })).toEqual([])
expect(readTicketItems(null)).toEqual([])
expect(readTicketItems({ ticket_json: '{oops' })).toEqual([])
})
it('round-trips through writeTicketItems', () => {
const items = readTicketItems({ ticket_json: receipt })
expect(readTicketItems({ ticket_json: writeTicketItems(items) })).toEqual(items)
})
})
describe('hasTicketSplit', () => {
it('recognises both storage shapes and nothing else', () => {
expect(hasTicketSplit({ ticket_json: '{"items":[]}' })).toBe(true)
expect(hasTicketSplit({ note: 'TICKETJSON:{"items":[]}' })).toBe(true)
expect(hasTicketSplit({ note: 'dinner with Ben' })).toBe(false)
expect(hasTicketSplit(undefined)).toBe(false)
})
})
describe('readUserNote', () => {
it('returns what the user typed', () => {
expect(readUserNote({ note: 'Lisa pays half back' })).toBe('Lisa pays half back')
})
it('never returns a receipt blob as if it were a note', () => {
expect(readUserNote({ note: 'TICKETJSON:{"items":[]}' })).toBe('')
expect(readUserNote({ note: null })).toBe('')
expect(readUserNote(null)).toBe('')
})
})
@@ -88,6 +88,53 @@ export interface TicketItem {
participants: Set<number>
}
/**
* Read the itemized receipt off an expense (#1658).
*
* It lives in `ticket_json` since migration 186. Before that it was smuggled
* through `note` behind a `TICKETJSON:` prefix, which is why an expense split by
* receipt could never carry a written note. The old shape is still read here so
* a response cached before the migration keeps rendering its split.
*/
export function readTicketItems(item: { ticket_json?: string | null; note?: string | null } | null | undefined): TicketItem[] {
const raw = item?.ticket_json ?? (item?.note?.startsWith('TICKETJSON:') ? item.note.slice(11) : null)
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return (parsed.items || []).map((line: { name?: string; price?: unknown; parts?: number[] }, i: number) => ({
id: `${i}`,
name: String(line.name ?? ''),
price: String(line.price ?? ''),
participants: new Set(line.parts || []),
}))
} catch {
return []
}
}
/** True when this expense is split line by line rather than equally or by amount. */
export function hasTicketSplit(item: { ticket_json?: string | null; note?: string | null } | null | undefined): boolean {
return Boolean(item?.ticket_json || item?.note?.startsWith('TICKETJSON:'))
}
/** Serialize the receipt lines for storage in `ticket_json`. */
export function writeTicketItems(items: TicketItem[]): string {
return JSON.stringify({ items: items.map(i => ({ name: i.name, price: i.price, parts: [...i.participants] })) })
}
/** Long enough for a receipt discrepancy or a reimbursement reminder, short
* enough that the row stays a row. */
export const NOTE_MAX = 500
/**
* The note a user actually typed, which is never the receipt blob. Only matters
* for data written before migration 186 moved the receipt out of the field.
*/
export function readUserNote(item: { note?: string | null } | null | undefined): string {
const note = item?.note
return note && !note.startsWith('TICKETJSON:') ? note : ''
}
/** Per-person totals for a receipt split line by line, plus the receipt total. */
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
@@ -171,6 +171,79 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
// ── Notes on an expense (#1658) ────────────────────────────────────────────
it('shows a note on the row and expands it on click', async () => {
const long = 'Bought the whole week of breakfasts here, Ben pays back his half once the card statement lands'
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Supermarket' }),
total_price: 60,
note: long,
payers: [{ user_id: 1, amount: 60, username: 'alice' }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// Re-queried after each click: the row is re-created on every render of the
// panel, so a handle taken before the click points at a detached node.
const noteToggle = () => screen.getByRole('button', { name: new RegExp(long.slice(0, 20)) })
await screen.findByText('Supermarket')
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
await user.click(noteToggle())
expect(noteToggle()).toHaveAttribute('aria-expanded', 'true')
await user.click(noteToggle())
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
})
it('never renders a receipt blob as a note', async () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Market' }),
total_price: 20,
// How the receipt was stored before it got its own column.
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"20","parts":[1]}]}',
payers: [{ user_id: 1, amount: 20, username: 'alice' }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Market')
expect(screen.queryByText(/TICKETJSON/)).toBeNull()
})
it('keeps an existing note when the expense is saved again', async () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Lunch' }),
total_price: 30,
note: 'split with the neighbours',
payers: [{ user_id: 1, amount: 30, username: 'alice' }],
}
let patched: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.put('/api/trips/1/budget/:id', async ({ request }) => {
patched = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: { ...item, ...patched } })
}),
)
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Lunch')
await user.click(screen.getAllByTitle('Edit')[0])
await user.click(await screen.findByRole('button', { name: 'Save' }))
await waitFor(() => expect(patched).toBeTruthy())
expect(patched!.note).toBe('split with the neighbours')
})
it('shows the net hint on a settled expense row and hides it while unfinished', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
@@ -534,7 +607,9 @@ describe('CostsPanel — settlements in the ledger', () => {
expect.objectContaining({ user_id: 1, amount: 75 }),
expect.objectContaining({ user_id: 2, amount: 25 }),
]))
expect(posted!.note).toContain('TICKETJSON:')
// The receipt has its own column since #1658; `note` is the user's text.
expect(JSON.parse(posted!.ticket_json as string).items).toHaveLength(3)
expect(posted!.note).toBeNull()
})
// ── Display currency ───────────────────────────────────────────────────────
+144 -70
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -16,7 +16,7 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { calculateTicketShares, payersBalanced, rebalancePayers, splitEqualShares, type TicketItem } from './CostsPanel.helpers'
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
@@ -80,6 +80,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
// One note open at a time: two expanded rows next to each other read as a mess,
// and the point of the collapse is that the list stays scannable.
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
@@ -246,8 +249,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
for (const e of items) {
const cur = curOf(e)
// Ticket notes carry the itemized-receipt JSON, not a human note.
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
const note = readUserNote(e)
rows.push([
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
@@ -502,6 +504,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
.costs-root .exp-actions { opacity: 1; }
.costs-root .exp-actions button { background: none; }
.costs-root .exp-actions button:hover { background: var(--bg-secondary); color: var(--c-ink); }
/* Destructive actions keep their own tint: the neutral one would read as
"safe" on the button that deletes the expense. */
.costs-root .exp-actions .exp-action-danger:hover { background: rgba(220,38,38,0.14); color: #dc2626; }
@media (max-width: 1100px) {
.costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; }
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
@@ -653,6 +660,28 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
// ── inline subcomponents (close over helpers) ────────────────────────────
/**
* Edit and delete, in a capsule beside the row rather than inside it — the
* mobile card has always done it this way, and out here the buttons stop
* competing with the amount for the right edge of the card.
*/
function RowActions({ onEdit, onDelete, deleteLabel }: { onEdit: () => void; onDelete: () => void; deleteLabel: string }) {
// No `background` here on purpose: an inline value would beat the :hover
// rule in the stylesheet block below, which is how the first attempt at
// this silently did nothing.
const btn: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 999, border: 0,
cursor: 'pointer', padding: 0,
}
return (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)
}
function ExpenseRow({ e }: { e: BudgetItem }) {
const c = catMeta(e.category)
const Icon = c.Icon
@@ -660,17 +689,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e)
const note = readUserNote(e)
const open = expandedNoteId === e.id
// Fixed columns, identical on every row, so the eye can run down the list
// instead of re-finding each field on each line. A row without a note leaves
// its column empty rather than letting everything after it drift left, which
// is what made the list read as unstructured.
const cols = isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto'
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-surface-card border border-edge exp-row" style={{ position: 'relative', flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: cols, gap: 18, alignItems: 'center', borderRadius: 18, padding: isMobile ? '16px 20px' : '20px 20px 16px' }}>
{/* The category rides the card edge as a tab, the way the mobile card
does, so it stops taking up a slot inside the row itself. */}
{!isMobile && (
<span aria-hidden="true" style={{ position: 'absolute', left: -1, top: -1, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 11px 4px 10px', borderRadius: '17px 0 12px 0', background: c.color, color: '#fff', fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 800, letterSpacing: '0.05em', textTransform: 'uppercase', lineHeight: 1.2 }}>
<Icon size={10} strokeWidth={2.4} />
{t(c.labelKey)}
</span>
)}
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && unfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
{/* What it was. */}
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
{unfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
@@ -678,8 +728,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</span>
)}
</div>
{cur !== base && (
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))}
</div>
)}
{/* Under the name, because a chip reading "Y 122,00" says nothing on
its own — the name above it is what gives it a meaning. */}
{payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 6 }}>
{payers.map(p => (
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
<Avatar id={p.user_id} size={18} />
@@ -688,29 +745,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
))}
</div>
)}
{!isMobile && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
</div>
)}
</div>
{/* The note, marked by a rule in the category colour instead of a box, so
it reads as part of the row rather than something dropped onto it. */}
{!isMobile && (
note ? (
<button type="button" onClick={() => setExpandedNoteId(open ? null : e.id)}
aria-expanded={open} title={open ? t('costs.hideNote') : t('costs.showNote')}
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-colors exp-note-btn"
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderLeft: '3px solid ' + c.color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'left' }}>
<span style={open
? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', minWidth: 0, flex: 1 }
: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }}>{note}</span>
<ChevronDown size={13} style={{ flexShrink: 0, marginTop: open ? 3 : 0, transition: 'transform .18s ease', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
) : <span />
)}
{/* The money, and what to do with it. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4, whiteSpace: 'nowrap' }}>
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: isMobile ? '0' : '6px 13px', borderRadius: 999, border: isMobile ? 0 : undefined, background: isMobile ? 'none' : undefined, fontSize: isMobile ? 'calc(18px * var(--fs-scale-subtitle, 1))' : 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{fmt(baseTotal(e))}</span>
{!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', padding: isMobile ? 0 : '2px 9px', borderRadius: 999, background: isMobile ? 'none' : (net > 0 ? 'rgba(22,163,74,0.13)' : 'rgba(220,38,38,0.13)'), fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div>
</span>
)}
</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)}
</div>
</div>
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
</div>
)
}
@@ -720,7 +786,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// Legacy transfers carry no currency and were entered in the display base.
const cur = (s.currency || base).toUpperCase()
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
@@ -732,16 +799,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
</div>
</div>
{/* A payment carries no note, but it keeps the column so its amount lands
on the same axis as every expense above it. */}
{!isMobile && <span />}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(convert(s.amount, cur))}</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: '6px 13px', borderRadius: 999, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(convert(s.amount, cur))}</span>
</div>
</div>
{canEdit && (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
)
}
@@ -936,6 +1007,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}) {
const { t, locale } = useTranslation()
const toast = useToast()
const isMobile = useIsMobile()
const { addBudgetItem, updateBudgetItem } = useTripStore()
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
@@ -944,6 +1016,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [note, setNote] = useState(() => readUserNote(editing))
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
@@ -977,7 +1050,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
if (hasTicketSplit(editing)) {
return 'ticket'
}
if (editing && editing.members && editing.members.length > 0) {
@@ -987,22 +1060,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: any) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set(item.parts || [])
}))
} catch {
return []
}
}
return []
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
@@ -1191,13 +1249,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
member_ids: [...participants],
expense_date: day || null,
total_price: totalNum,
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
items: ticketItems.map(item => ({
name: item.name,
price: item.price,
parts: [...item.participants]
}))
}) : null,
note: note.trim() || null,
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
@@ -1212,17 +1265,25 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
const labelCls = 'block text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2'
// Borrowed from the trip dialog (TripFormModal): related fields sit together in
// a panel instead of running as one undifferentiated column of inputs.
const panelCls = 'rounded-2xl border border-edge bg-surface-secondary p-4'
return (
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Two columns on desktop: what the expense was on the left, how it is
shared on the right. The split follows the two questions the dialog
actually asks, so neither side is a leftovers pile. */}
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', gridTemplateColumns: '1fr 1fr', alignItems: 'start', gap: isMobile ? 14 : 28 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div className={panelCls} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className={labelCls}>{t('costs.whatFor')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} />
@@ -1260,7 +1321,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
)}
<div>
</div>
<div className={panelCls}>
<label className={labelCls}>{t('costs.category')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{COST_CATEGORY_LIST.map(c => {
@@ -1277,8 +1340,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.whoPaid')}</label>
<button type="button" onClick={() => (multiPayer ? disableMultiPayer() : enableMultiPayer())}
className="text-content-muted"
@@ -1340,23 +1406,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
)}
</div>
<div>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-card border border-edge" style={{ display: 'flex', borderRadius: 999, padding: 2 }}>
<button type="button" onClick={() => setSplitMode('equally')}
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
className={splitMode === 'equally' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'equally' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitEqually') || 'Equally'}
</button>
<button type="button" onClick={() => setSplitMode('custom')}
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
className={splitMode === 'custom' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'custom' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitCustom') || 'Custom'}
</button>
<button type="button" onClick={() => setSplitMode('ticket')}
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
className={splitMode === 'ticket' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'ticket' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitTicket') || 'Ticket'}
</button>
</div>
@@ -1491,6 +1557,14 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</>
)}
</div>
<div className={panelCls}>
<label className={labelCls} htmlFor="expense-note">{t('costs.note')}</label>
<textarea id="expense-note" value={note} onChange={e => setNote(e.target.value)} rows={2}
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
</div>
</div>
</div>
</Modal>
)
@@ -131,4 +131,16 @@ describe('CollectionFilterBar', () => {
render(<Harness {...makeProps({ showSelect: false })} />);
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
});
it('FE-COMP-COLFILTERBAR-009: the active Select button marks itself with `on`, not `open`', () => {
// `.open` belongs to the dropdowns, where it means the panel is down and the
// label stays dark. Sharing that class made the active button paint a dark
// icon on its dark accent fill, because the dropdown rule sits later in the
// stylesheet and both have the same specificity.
render(<Harness {...makeProps({ selectMode: true })} />);
const selectBtn = screen.getByRole('button', { name: 'Select' });
expect(selectBtn).toHaveClass('on');
expect(selectBtn).not.toHaveClass('open');
expect(selectBtn).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
import CollectionLabelFilter from './CollectionLabelFilter'
interface Opt {
key: string | number
@@ -148,36 +149,26 @@ export default function CollectionFilterBar({
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
<Dropdown current={sortMode} options={sortOpts} onSelect={k => onSortMode(k as CollectionSortMode)} lead={<ArrowDownUp size={13} />} />
{showSelect && (
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
<button
type="button"
onClick={onToggleSelect}
className={`col-filter-btn col-filter-icon col-filter-select${selectMode ? ' on' : ''}`}
aria-pressed={selectMode}
aria-label={t('collections.select')}
title={t('collections.select')}
>
<CheckSquare size={15} />
</button>
)}
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
<div className="col-labelfilter">
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
)
})}
{canManageLabels && (
<button type="button" className="col-filter-btn col-filter-addlabel" onClick={onManageLabels} title={t('collections.labels.manage')}>
<Tags size={13} />
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
</button>
)}
</div>
<CollectionLabelFilter
labelOptions={labelOptions}
labelFilter={labelFilter}
onLabelFilter={onLabelFilter}
canManageLabels={canManageLabels}
onManageLabels={onManageLabels}
t={t}
/>
)}
</div>
)
@@ -0,0 +1,72 @@
import React from 'react'
import { Plus, Settings2, Tags } from 'lucide-react'
import type { LabelOption } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
export type { LabelOption }
interface CollectionLabelFilterProps {
labelOptions: LabelOption[]
labelFilter: number[]
onLabelFilter: (ids: number[]) => void
canManageLabels?: boolean
onManageLabels?: () => void
/**
* 'bar' is the filter row above the list, 'map' the strip floating over the
* map, which needs the same glass treatment as the buttons beside it.
*/
variant?: 'bar' | 'map'
t: TranslationFn
}
/**
* The label filter, in one place because it has two homes.
*
* Labels are an axis of their own next to status, category and rating, so they
* are grouped into a tray instead of joining the row as more loose buttons. On
* a desktop it rides in the map's top bar, where there is room to spare; the
* filter row keeps it whenever no map is on screen, so it never becomes
* unreachable.
*/
export default function CollectionLabelFilter({
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
variant = 'bar', t,
}: CollectionLabelFilterProps): React.ReactElement {
return (
<div
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
role="group"
aria-label={t('collections.labels.manage')}
>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
)
})}
{canManageLabels && onManageLabels && (
<button
type="button"
className="col-filter-addlabel"
onClick={onManageLabels}
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)}
</div>
)
}
@@ -3,6 +3,7 @@ import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
import type { CollectionPlace } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionMap from './CollectionMap'
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter'
interface CollectionMapPanelProps {
places: CollectionPlace[]
@@ -17,6 +18,16 @@ interface CollectionMapPanelProps {
onToggleView: () => void
search: string
onSearch: (v: string) => void
/**
* The label filter rides along in the map's top bar, where there is room for
* it. The filter row keeps it whenever no map is on screen, so it can never
* become unreachable.
*/
labelOptions?: LabelOption[]
labelFilter?: number[]
onLabelFilter?: (ids: number[]) => void
canManageLabels?: boolean
onManageLabels?: () => void
t: TranslationFn
}
@@ -27,8 +38,10 @@ interface CollectionMapPanelProps {
*/
export default function CollectionMapPanel({
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
search, onSearch, t,
search, onSearch, labelOptions = [], labelFilter = [], onLabelFilter, canManageLabels = false,
onManageLabels, t,
}: CollectionMapPanelProps): React.ReactElement {
const showLabels = onLabelFilter != null && (labelOptions.length > 0 || canManageLabels)
return (
<div className="col-map-shell">
<CollectionMap
@@ -50,6 +63,17 @@ export default function CollectionMapPanel({
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
{showLabels && (
<CollectionLabelFilter
variant="map"
labelOptions={labelOptions}
labelFilter={labelFilter}
onLabelFilter={onLabelFilter!}
canManageLabels={canManageLabels}
onManageLabels={onManageLabels}
t={t}
/>
)}
</div>
<div className="col-map-group right">
<div className="col-map-search">
@@ -342,7 +342,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
{dayDetailPlugins.map((p) => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} dayId={String(day.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={String(tripId)} dayId={String(day.id)} title={p.name} surface="detail-slot" />
</div>
))}
</div>
@@ -43,7 +43,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
interface DayPlanSidebarProps {
tripId: number
@@ -1701,12 +1701,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => {
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
// Flat entries, one per app: the menu has room to
// grow and a submenu would need a component that
// does not exist here.
const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
@@ -1,4 +1,4 @@
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
import { render, screen, waitFor, fireEvent, act, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
@@ -632,13 +632,18 @@ describe('PlaceInspector', () => {
// ── Google Maps URL action ─────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-043: Google Maps lat/lng button visible when no google_maps_url', () => {
it('FE-PLANNER-INSPECTOR-043: the navigation button offers every app the place can be opened in', async () => {
const user = userEvent.setup();
render(<PlaceInspector {...defaultProps} />);
// place has lat/lng so Google Maps button should appear with Navigation icon
const allButtons = screen.getAllByRole('button');
// Find button containing "Google Maps" text
const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps'));
expect(mapsBtn).toBeTruthy();
// A place with coordinates reaches Google Maps and Waze (Apple Maps only on
// Apple platforms), so the button collects them behind one entry.
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
expect(navBtn).toBeTruthy();
await user.click(navBtn);
const menu = await screen.findByRole('menu');
expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument();
expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument();
});
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
@@ -651,9 +656,10 @@ describe('PlaceInspector', () => {
lng: -80.5542617,
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
})} />);
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
await user.click(mapsBtn);
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
await user.click(navBtn);
await user.click(await screen.findByRole('menuitem', { name: 'Google Maps' }));
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank', 'noopener,noreferrer');
openSpy.mockRestore();
});
@@ -934,12 +940,17 @@ describe('PlaceInspector', () => {
// ── Footer actions ───────────────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-069: the OpenStreetMap and website buttons open their targets in a new tab', () => {
it('FE-PLANNER-INSPECTOR-069: OpenStreetMap sits in the navigation menu, the website keeps its own button', async () => {
const user = userEvent.setup();
const open = vi.fn();
vi.stubGlobal('open', open);
const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' });
render(<PlaceInspector {...defaultProps} place={p} />);
fireEvent.click(screen.getByText('OpenStreetMap').closest('button')!);
// OSM moved in with the other map apps rather than standing beside them.
await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!);
await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' }));
fireEvent.click(screen.getByText('Open Website').closest('button')!);
expect(open).toHaveBeenCalledTimes(2);
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']);
@@ -27,8 +27,8 @@ import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
@@ -178,6 +178,8 @@ export default function PlaceInspector({
// Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe:
// any provider error/timeout is dropped server-side, so this only ever adds rows.
const [providerDetails, setProviderDetails] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
const [navOpen, setNavOpen] = useState(false)
const navBtnRef = useRef<HTMLButtonElement>(null)
const placeIdForDetails = mode === 'trip' ? place?.id : undefined
useEffect(() => {
if (placeIdForDetails == null) { setProviderDetails([]); return }
@@ -316,11 +318,10 @@ export default function PlaceInspector({
googleDetails?.open_now,
)
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
const googleMapsUrl = getGoogleMapsUrlForPlace(
const navigationTargets = getNavigationTargets(
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
googleDetails?.google_maps_url,
)
const openStreetMapUrl = getOpenStreetMapUrlForPlace(place)
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
@@ -450,7 +451,7 @@ export default function PlaceInspector({
const tid = (place as { trip_id?: number | string }).trip_id
return (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} surface="detail-slot" />
</div>
)
})}
@@ -484,13 +485,32 @@ export default function PlaceInspector({
icon={savedInCollection ? <BookmarkCheck size={13} /> : <Bookmark size={13} />}
label={<span className="hidden sm:inline">{savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}</span>} />
)}
{googleMapsUrl && (
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{openStreetMapUrl && (
<ActionButton onClick={() => window.open(openStreetMapUrl, '_blank')} variant="ghost" icon={<MapIcon size={13} />}
label={<span className="hidden sm:inline">{t('inspector.openStreetMap')}</span>} />
{navigationTargets.length > 0 && (
<>
{/* One target left (a place without coordinates) opens straight
away, exactly as this button always did. */}
<ActionButton
ref={navBtnRef}
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavOpen(o => !o)
}}
variant="ghost"
icon={<Navigation size={13} />}
label={
<span className="hidden sm:inline">
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
</span>
}
/>
{navOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
/>
)}
</>
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
@@ -535,9 +555,11 @@ interface ActionButtonProps {
variant: 'primary' | 'ghost' | 'danger'
icon: React.ReactNode
label: React.ReactNode
/** For callers that anchor a popup to the button. React 19 passes it through. */
ref?: React.Ref<HTMLButtonElement>
}
export function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
export function ActionButton({ onClick, variant, icon, label, ref }: ActionButtonProps) {
const base = {
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
@@ -546,6 +568,7 @@ export function ActionButton({ onClick, variant, icon, label }: ActionButtonProp
const s = base[variant] || base.ghost
return (
<button
ref={ref}
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 5,
@@ -672,6 +672,28 @@ describe('track colour legend (#776)', () => {
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
expect(strokes).toHaveLength(0);
});
it('FE-PLANNER-SIDEBAR-051: the star button filters the list down to a minimum rating', async () => {
// Replaces the old sort toggle: sorting put the best first but still left
// every other place on the list, which is no help when the point is to see
// only what the group actually wants to do.
const user = userEvent.setup();
const places = [
buildPlace({ id: 20, name: 'Loved Place', rating_avg: 4.6 }),
buildPlace({ id: 21, name: 'Meh Place', rating_avg: 2.1 }),
buildPlace({ id: 22, name: 'Unrated Place' }),
];
render(<PlacesSidebar {...defaultProps} places={places} />);
expect(screen.getByText('Meh Place')).toBeInTheDocument();
await user.click(screen.getByLabelText('Filter by rating'));
await user.click(screen.getByText('4+'));
expect(screen.getByText('Loved Place')).toBeInTheDocument();
expect(screen.queryByText('Meh Place')).not.toBeInTheDocument();
// An unrated place has no average to clear the floor with.
expect(screen.queryByText('Unrated Place')).not.toBeInTheDocument();
});
});
// #1616 — the other half of the reporter's gesture: the pickup. A tablet cannot
@@ -25,7 +25,8 @@ export function PlacesHeader(S: SidebarState) {
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
ratingSort, setRatingSort,
ratingFilter, setRatingFilter,
starDropOpen, setStarDropOpen,
} = S
return (
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
@@ -150,8 +151,10 @@ export function PlacesHeader(S: SidebarState) {
)}
</div>
{/* Category multi-select dropdown */}
{categories.length > 0 && (() => {
{/* Category multi-select plus the star filter. Rendered even without any
category, because the rating filter does not depend on one and a fresh
trip is exactly where someone collects places to rate. */}
{(() => {
const label = categoryFilters.size === 0
? t('places.allCategories')
: categoryFilters.size === 1
@@ -159,7 +162,7 @@ export function PlacesHeader(S: SidebarState) {
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
return (
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
{categories.length > 0 && <button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
@@ -167,26 +170,64 @@ export function PlacesHeader(S: SidebarState) {
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} className="text-content-faint" style={{ flexShrink: 0, transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{/* Star sort (#1435): order the list by average rating, best first. */}
<Tooltip label={t('places.sortByRating')} placement="bottom">
</button>}
{/* Minimum-stars filter (#1435), same floors as the collections bar. */}
<Tooltip label={t('places.filterByRating')} placement="bottom">
<button
onClick={() => setRatingSort(v => !v)}
aria-label={t('places.sortByRating')}
aria-pressed={ratingSort}
className={ratingSort ? 'text-accent' : 'text-content-faint'}
onClick={() => { setStarDropOpen(v => !v); setCatDropOpen(false) }}
aria-label={t('places.filterByRating')}
aria-expanded={starDropOpen}
className={ratingFilter !== 'all' ? 'text-accent' : 'text-content-faint'}
style={{
width: 30, flexShrink: 0, borderRadius: 8,
border: `1px solid ${ratingSort ? 'var(--accent)' : 'var(--border-primary)'}`,
background: ratingSort ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
height: 30, flexShrink: 0, borderRadius: 8, gap: 3,
padding: ratingFilter === 'all' ? 0 : '0 7px',
width: ratingFilter === 'all' ? 30 : undefined,
border: `1px solid ${ratingFilter !== 'all' ? 'var(--accent)' : 'var(--border-primary)'}`,
background: ratingFilter !== 'all' ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
}}
>
<Star size={13} strokeWidth={2.2} fill={ratingSort ? 'currentColor' : 'none'} />
<Star size={13} strokeWidth={2.2} fill={ratingFilter !== 'all' ? 'currentColor' : 'none'} />
{ratingFilter !== 'all' && <span>{ratingFilter}+</span>}
</button>
</Tooltip>
{starDropOpen && (
<div className="bg-surface-card" style={{
position: 'absolute', top: '100%', right: 0, zIndex: 50, marginTop: 4, minWidth: 116,
border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4,
}}>
{(['all', 5, 4, 3, 2, 1] as const).map(opt => {
const active = ratingFilter === opt
return (
<button
key={String(opt)}
onClick={() => { setRatingFilter(opt as number | 'all'); setStarDropOpen(false) }}
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
textAlign: 'left',
}}
>
{opt === 'all'
? <span style={{ flex: 1 }}>{t('common.all')}</span>
: (
<>
<Star size={12} strokeWidth={2.2} color="#facc15" fill="#facc15" />
<span style={{ flex: 1 }}>{opt}+</span>
</>
)}
{active && <Check size={11} strokeWidth={3} className="text-content-faint" />}
</button>
)
})}
</div>
)}
{canEditPlaces && (
<Tooltip label={t('common.select')} placement="bottom">
<button
@@ -222,7 +263,7 @@ export function PlacesHeader(S: SidebarState) {
</button>
</Tooltip>
)}
{catDropOpen && (
{catDropOpen && categories.length > 0 && (
<div className="bg-surface-card" style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
border: '1px solid var(--border-primary)', borderRadius: 10,
@@ -443,7 +443,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '0 14px 14px' }}>
{detailPlugins.map(p => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
</div>
))}
</div>
@@ -623,7 +623,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{detailPlugins.map(p => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
</div>
))}
</div>
@@ -24,7 +24,16 @@ describe('getGoogleMapsUrlForPlace', () => {
expect(url).toBe('https://maps.google.com/?cid=123')
})
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
it('FE-PLACE-GMAPS-005: searches by name and address when Google knows the place by neither id', () => {
// #1278: coordinates alone open a nameless pin rather than the place, and
// Google documents query=PLACE_NAME,ADDRESS for precisely this case.
const url = getGoogleMapsUrlForPlace({ ...base, address: 'Champ de Mars, 75007 Paris' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower%2C%20Champ%20de%20Mars%2C%2075007%20Paris')
})
it('FE-PLACE-GMAPS-005b: a name with no address still falls back to coordinates', () => {
// "Central Station" on its own would happily match a different city; the
// position is the safer answer even though the pin carries no label.
const url = getGoogleMapsUrlForPlace(base)
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
})
@@ -33,4 +42,9 @@ describe('getGoogleMapsUrlForPlace', () => {
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
})
it('FE-PLACE-GMAPS-007: an address alone carries the link even without coordinates', () => {
const url = getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null, address: 'Champ de Mars, 75007 Paris' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower%2C%20Champ%20de%20Mars%2C%2075007%20Paris')
})
})
@@ -1,6 +1,6 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
@@ -14,6 +14,19 @@ export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, de
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
}
if (detailsUrl) return detailsUrl
// No Google identifier of any kind. Coordinates alone drop a pin on a spot
// with no name attached, which is what #1278 reported: the map opens, but not
// the place. Google documents `query=PLACE_NAME,ADDRESS` for exactly this
// case, and an address is specific enough that the name cannot match the
// wrong "Central Station" three cities over.
const name = place.name?.trim()
const address = place.address?.trim()
if (name && address) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${name}, ${address}`)}`
}
if (place.lat == null || place.lng == null) return null
// A name without an address is not safe to search on its own, so the position
// wins: a pin in the right spot beats a confident link to the wrong place.
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
}
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
import type { Place } from '../../types'
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
function place(overrides: Partial<Place> = {}): Place {
return {
name: 'Stephansdom',
lat: 48.2038,
lng: 16.3616,
google_place_id: null,
google_ftid: null,
...overrides,
} as unknown as Place
}
/**
* jsdom reports its own agent, so Apple has to be simulated. userAgent lives on
* the prototype as a getter: shadowing it on the instance and deleting that
* again afterwards restores the real one without having to reconstruct it.
*/
function withUserAgent(ua: string) {
Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true })
return () => { delete (navigator as unknown as Record<string, unknown>).userAgent }
}
afterEach(() => { vi.restoreAllMocks() })
describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets[0].label).toBe('Google Maps')
})
it('FE-PLANNER-NAV-002: Waze gets the name as well as the position, and navigates', () => {
// Coordinates alone would show a driver a number instead of a destination;
// the name alone would be a guess. Waze documents taking both.
const waze = getNavigationTargets(place()).find(t => t.id === 'waze')!
expect(waze.url).toBe('https://waze.com/ul?q=Stephansdom&ll=48.2038,16.3616&navigate=yes')
})
it('FE-PLANNER-NAV-003: Google keeps its precise link when the place has an ftid', () => {
const targets = getNavigationTargets(place({ google_ftid: '0x882b:0x8591' }))
// The whole point of the ftid: it lands on the entry, not on the roof.
expect(targets[0].url).toContain('ftid=0x882b:0x8591')
expect(targets[0].url).not.toContain('query=48.2038')
})
it('FE-PLANNER-NAV-004: a Windows desktop gets Apple Maps too, through its web version', () => {
const restore = withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toContain('apple')
} finally { restore() }
})
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
} finally { restore() }
})
it('FE-PLANNER-NAV-005: an iPhone gets Apple Maps with a navigation target', () => {
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
try {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
// q next to ll labels the pin rather than searching blindly.
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
} finally { restore() }
})
it('FE-PLANNER-NAV-006: iPadOS reports itself as a Mac, and both deserve the entry', () => {
const restore = withUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
try {
expect(showsAppleMaps()).toBe(true)
} finally { restore() }
})
it('FE-PLANNER-NAV-007: a place without coordinates keeps only what Google can resolve', () => {
// Waze and Apple Maps take coordinates and nothing else, so they cannot
// offer anything here — Google still can, through the place id.
// OpenStreetMap still manages a name search, the two driving apps do not.
const targets = getNavigationTargets(place({ lat: null, lng: null, google_place_id: 'ChIJabc' }))
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
})
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
expect(getNavigationTargets(null)).toEqual([])
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
})
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
const targets = getNavigationTargets(place({ name: '' }))
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
})
})
@@ -0,0 +1,95 @@
import type { AssignmentPlace, Place } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
export interface NavigationTarget {
id: NavigationAppId
/** Product name. Not translated in any language, so it carries no i18n key. */
label: string
url: string
}
/**
* Whether Apple Maps is worth offering.
*
* Apple platforms open the installed app. Everywhere else the link still works,
* because Apple Maps has had a web version since 2024, so a Windows or Linux
* desktop gets a perfectly usable map rather than a dead end.
*
* Android is the one place it stays hidden: the web version works there too,
* but nobody navigating from an Android phone reaches for Apple Maps, and the
* row of choices is short for a reason.
*
* iPadOS 13 and later report themselves as "Macintosh", which is why the Mac
* branch is not narrowed by touch: a real Mac has the app, an iPad has the app,
* so both sides of that ambiguity are correct.
*/
export function showsAppleMaps(): boolean {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent
if (/iPhone|iPad|iPod|Macintosh/.test(ua)) return true
return !/Android/i.test(ua)
}
/**
* The map apps a place can be opened in, in the order they are offered.
*
* None of them gets bare coordinates. Waze and Apple Maps both take a query
* alongside the position (`q` in each case, documented by both), and the pair
* is what makes the destination legible: the coordinates anchor which place is
* meant, the name is what the driver sees on the screen instead of a number.
* Without the position a name alone would be a gamble — there are a lot of
* places called "Bahnhof" — so a place TREK has no coordinates for reaches
* neither app.
*
* Google is the exception and keeps the link it always had, because it can do
* better than a name: `getGoogleMapsUrlForPlace` walks ftid, then place id,
* then the details URL, which lands on the right entry inside a mall rather
* than on the roof.
*
* Waze arms navigation, since driving is the only thing it does. The other
* three open the place, which is what Google has always done here, and starting
* navigation from there is one tap.
*/
export function getNavigationTargets(
place: PlaceLike | null | undefined,
detailsUrl?: string | null,
): NavigationTarget[] {
if (!place) return []
const targets: NavigationTarget[] = []
const name = place.name?.trim()
const googleUrl = getGoogleMapsUrlForPlace(place, detailsUrl)
if (googleUrl) targets.push({ id: 'google', label: 'Google Maps', url: googleUrl })
if (place.lat != null && place.lng != null) {
const ll = `${place.lat},${place.lng}`
const q = name ? `q=${encodeURIComponent(name)}&` : ''
targets.push({
id: 'waze',
label: 'Waze',
url: `https://waze.com/ul?${q}ll=${ll}&navigate=yes`,
})
if (showsAppleMaps()) {
targets.push({
id: 'apple',
label: 'Apple Maps',
url: `https://maps.apple.com/?${q}ll=${ll}`,
})
}
}
const osmUrl = getOpenStreetMapUrlForPlace(place)
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
return targets
}
/** Opens a target the way every external link in the planner is opened. */
export function openNavigationTarget(target: NavigationTarget): void {
window.open(target.url, '_blank', 'noopener,noreferrer')
}
@@ -187,25 +187,29 @@ describe('usePlacesSidebar filtering', () => {
expect([...useTripStore.getState().placesCategoryFilter]).toEqual([]);
});
it('FE-PLANNER-PSHOOK-012: the star sort puts the best rating first and unrated places last', () => {
it('FE-PLANNER-PSHOOK-012: the star filter keeps everything at or above the floor', () => {
const places = [
buildPlace({ name: 'Unrated' }),
buildPlace({ name: 'Good', rating_avg: 4.2 }),
buildPlace({ name: 'Best', rating_avg: 4.9 }),
buildPlace({ name: 'Meh', rating_avg: 2.5 }),
];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingSort(true); });
expect(names()).toEqual(['Best', 'Good', 'Unrated']);
act(() => { S.setRatingFilter(4); });
// The list keeps its own order; the filter only decides who is on it.
expect(names()).toEqual(['Good', 'Best']);
});
it('FE-PLANNER-PSHOOK-013: equal ratings keep the incoming order', () => {
it('FE-PLANNER-PSHOOK-013: an unrated place drops out as soon as a floor is set, and comes back with "all"', () => {
const places = [
buildPlace({ name: 'First', rating_avg: 4 }),
buildPlace({ name: 'Second', rating_avg: 4 }),
buildPlace({ name: 'Unrated' }),
buildPlace({ name: 'Rated', rating_avg: 1 }),
];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingSort(true); });
expect(names()).toEqual(['First', 'Second']);
act(() => { S.setRatingFilter(1); });
expect(names()).toEqual(['Rated']);
act(() => { S.setRatingFilter('all'); });
expect(names()).toEqual(['Unrated', 'Rated']);
});
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
@@ -147,7 +147,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
const [selectMode, setSelectMode] = useState(false)
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
const [ratingSort, setRatingSort] = useState(false)
// Minimum average stars, matching the collections filter (#1435): 'all', or a
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
// which put the best first but still left everything else on the list — no
// help at all when the point is to see only what the group actually rated.
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
@@ -179,6 +183,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
}
const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
const [catDropOpen, setCatDropOpen] = useState(false)
const [starDropOpen, setStarDropOpen] = useState(false)
const [mobileShowDays, setMobileShowDays] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
@@ -201,15 +206,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
}
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
return true
})
// Star sort (#1435): highest average first, unrated places at the end;
// ties keep the list's original order (stable sort).
if (ratingSort) {
return [...list].sort((a, b) => (b.rating_avg ?? -1) - (a.rating_avg ?? -1))
}
return list
}, [places, filter, categoryFilters, search, plannedIds, ratingSort])
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
@@ -269,7 +270,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFilters,
ratingSort, setRatingSort,
ratingFilter, setRatingFilter,
starDropOpen, setStarDropOpen,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
@@ -115,7 +115,7 @@ export function PluginActions({ items, tripId, className }: { items: ViewContrib
<button onClick={() => setFrame(null)} className="text-content-muted hover:text-content"><X size={16} /></button>
</div>
<div className="flex-1 min-h-0">
<PluginFrame pluginId={frame.pluginId} tripId={tripId != null ? String(tripId) : null} title={frame.label} fill />
<PluginFrame pluginId={frame.pluginId} tripId={tripId != null ? String(tripId) : null} title={frame.label} fill surface="action-frame" />
</div>
</div>
</div>
@@ -109,6 +109,58 @@ describe('PluginFrame', () => {
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
});
it('FE-PLUGINS-FRAME-005a: the context says which surface the frame sits in and what shape it takes', () => {
// Without this a plugin cannot tell a full-height tab from a widget that
// reports its own height — and on a filling surface the height report is
// dropped, so it never finds out by trying.
const { container } = render(<PluginFrame pluginId="demo" surface="trip-tab" fill />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx!.viewport).toMatchObject({ surface: 'trip-tab', fill: true, formFactor: 'desktop' });
expect((ctx!.viewport as { insets: unknown }).insets).toEqual({ top: 0, bottom: 0 });
});
it('FE-PLUGINS-FRAME-005b: a widget reports its own height, and says so', () => {
const { container } = render(<PluginFrame pluginId="demo" surface="dashboard-widget" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx!.viewport).toMatchObject({ surface: 'dashboard-widget', fill: false });
});
it('FE-PLUGINS-FRAME-005c: the mobile palette is read off the mobile shell, not the document element', () => {
// The --m-* family is scoped to .m-root. Reading it at documentElement — where
// every other token lives — yields nothing, which is why it never reached a
// plugin before.
const shell = document.createElement('div');
shell.className = 'm-root';
shell.style.setProperty('--m-card', 'rgba(255,255,255,.55)');
shell.style.setProperty('--m-ink', '#101013');
document.body.appendChild(shell);
try {
const { container } = render(<PluginFrame pluginId="demo" surface="trip-tab" fill />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx!.tokens).toMatchObject({ '--m-card': 'rgba(255,255,255,.55)', '--m-ink': '#101013' });
} finally {
shell.remove();
}
});
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
+93 -2
View File
@@ -10,6 +10,7 @@ import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import { pluginsApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useIsPhone } from '../../mobile/useIsPhone'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
@@ -21,6 +22,31 @@ import { addListener, removeListener } from '../../api/websocket'
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
// bakes those values instead (they don't vary with the accent, only light/dark).
/**
* The mobile palette (client/src/mobile/mobile.css) is a SECOND, unrelated token
* family, and it is scoped to `.m-root` rather than to the document element. That
* scope is the reason it cannot simply join the list below: `readThemeTokens`
* measures at documentElement, where every one of these resolves empty — the same
* trap the comment above describes for `--glass-*`. They are read off the mobile
* shell instead, when one is mounted.
*
* A plugin sitting inside the mobile shell needs these to match its surroundings;
* the global palette above describes the desktop chrome and looks foreign there.
*/
const MOBILE_TOKEN_VARS = [
// ink + ground
'--m-ink', '--m-muted', '--m-faint', '--m-bg', '--m-scr',
// glass surfaces the mobile design is built from
'--m-glass', '--m-gbr', '--m-card', '--m-cbr', '--m-inner', '--m-inbr',
'--m-sheet', '--m-sheetop', '--m-shbr',
// action + accents
'--m-act', '--m-actfg', '--m-dim', '--m-ic', '--m-rowbr', '--m-avbr', '--m-knob',
// status canon (theme independent, but a plugin should not restate it)
'--m-st-confirmed', '--m-st-pending', '--m-st-info', '--m-st-danger', '--m-st-neutral',
// chrome metrics
'--m-safe-top',
]
const TOKEN_VARS = [
// surfaces
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
@@ -39,6 +65,9 @@ const TOKEN_VARS = [
// radii, type, misc
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
// How much a host surface keeps clear at the bottom. Global, unlike the rest of
// the mobile metrics, and useful on both form factors.
'--bottom-nav-h',
]
function readThemeTokens(): Record<string, string> {
const cs = getComputedStyle(document.documentElement)
@@ -47,9 +76,54 @@ function readThemeTokens(): Record<string, string> {
const val = cs.getPropertyValue(v).trim()
if (val) out[v] = val
}
// Measured on the mobile shell, because that is where the mobile palette is
// declared. Absent on desktop, and then these keys are simply absent too — a
// plugin checks for them rather than assuming both families are present.
const mobileRoot = document.querySelector('.m-root')
if (mobileRoot) {
const mcs = getComputedStyle(mobileRoot)
for (const v of MOBILE_TOKEN_VARS) {
const val = mcs.getPropertyValue(v).trim()
if (val) out[v] = val
}
}
return out
}
/** Where a frame is mounted. Part of the plugin contract, so the names are stable. */
export type PluginSurface =
| 'trip-tab' // a plugin's own tab inside a trip (fills, scrolls itself)
| 'plugin-page' // /plugins/:id (fills, scrolls itself)
| 'dashboard-widget'// a card on the dashboard (reports its height)
| 'user-settings' // the plugin's settings.html (reports its height)
| 'detail-slot' // inside a place/day/reservation panel (reports its height)
| 'action-frame' // the modal a table action opens (fills)
/**
* What the host already keeps clear around this frame, in CSS pixels.
*
* This describes space the plugin does NOT have to account for; it is not a
* request to add padding. The mobile trip tab is the only surface with floating
* chrome above and below it, and the numbers come from the same variables the
* native tabs use, so the two can never drift apart.
*/
function readInsets(surface: PluginSurface | undefined, phone: boolean): { top: number; bottom: number } {
if (!phone || surface !== 'trip-tab') return { top: 0, bottom: 0 }
const mobileRoot = document.querySelector('.m-root')
if (!mobileRoot) return { top: 0, bottom: 0 }
const cs = getComputedStyle(mobileRoot)
const px = (value: string, fallback: number) => {
const n = Number.parseFloat(value)
return Number.isFinite(n) ? n : fallback
}
// Mirrors TabScroller: the controls end 58px below the safe-top anchor, and the
// dock plus its breathing room is what --bottom-nav-h already expresses.
return {
top: px(cs.getPropertyValue('--m-safe-top'), 12) + 58,
bottom: px(cs.getPropertyValue('--bottom-nav-h'), 84) + 22,
}
}
/**
* The host's current appearance state, mirrored from the attributes applyAppearance
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
@@ -97,6 +171,13 @@ interface PluginFrameProps {
* below — right for dashboard widgets, wrong for a page.
*/
fill?: boolean
/**
* Which host surface this frame is mounted in. Handed to the plugin verbatim,
* because the contract differs per surface and a plugin cannot see where it
* sits: a page fills and scrolls itself, a widget reports its height and gets
* it honoured, a detail slot is a narrow column inside someone else's panel.
*/
surface?: PluginSurface
className?: string
title?: string
/**
@@ -208,7 +289,8 @@ function createPluginSessionStorageKeys(
}
}
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title, path = 'index.html' }: PluginFrameProps) {
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, surface, className, title, path = 'index.html' }: PluginFrameProps) {
const isPhone = useIsPhone()
const frameRef = useRef<HTMLIFrameElement | null>(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
// window identity keeps matching our iframe afterwards. Track loads and refuse
@@ -282,6 +364,15 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
// mirror the same look and accessibility choices as the host.
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
appearance: readAppearance(),
// Where this frame sits and what shape it is expected to take. Without it a
// plugin cannot tell a full-height tab from a widget that reports its own
// height, and a height report is silently dropped on a filling surface.
viewport: {
surface: surface ?? null,
formFactor: isPhone ? 'phone' : 'desktop',
fill,
insets: readInsets(surface, isPhone),
},
formats: {
locale,
currency: displayCurrency,
@@ -292,7 +383,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
blurBookingCodes: Boolean(settings.blur_booking_codes),
},
tokens: readThemeTokens(),
}), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings])
}), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings, surface, fill, isPhone])
useEffect(() => {
const frame = frameRef.current
@@ -18,13 +18,19 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
{plugins.map((p) => (
<div
key={p.id}
// The glass vocabulary (--glass-*, --r-xl, --ink-3) is scoped to
// .trek-dash. The phone dashboard is a different route and mounts these
// widgets outside that subtree, so every one of those resolved empty and
// the card lost its background, border, shadow and blur — a bare title
// over the gradient. Each now falls back to a mobile token, which is
// what the surrounding cards on that screen are made of.
style={{
background: 'var(--glass-bg)',
border: '1px solid var(--glass-border)',
background: 'var(--glass-bg, var(--m-card))',
border: '1px solid var(--glass-border, var(--m-cbr))',
borderRadius: 'var(--r-xl, 20px)',
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
boxShadow: 'var(--glass-shadow, 0 16px 44px -20px rgba(0,0,0,.28)), var(--glass-highlight, 0 0 0 0 transparent)',
backdropFilter: 'var(--glass-blur, blur(30px) saturate(1.8))',
WebkitBackdropFilter: 'var(--glass-blur, blur(30px) saturate(1.8))',
overflow: 'hidden',
}}
>
@@ -32,7 +38,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
color: 'var(--ink-3)',
color: 'var(--ink-3, var(--m-faint))',
}}
>
<PluginIcon name={p.icon} size={14} style={{ flexShrink: 0 }} />
@@ -40,7 +46,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
</div>
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
<div style={{ minHeight: 60 }}>
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} surface="dashboard-widget" />
</div>
</div>
))}
@@ -242,7 +242,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
white canvas behind the transparent frame (same trap .hero-overlay-frame
guards against), which glares in dark mode. */}
<div className="min-h-[120px]">
<PluginFrame pluginId={id} path="settings.html" title={name} className="[color-scheme:light]" />
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
</div>
</div>
)
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { NavigationMenu } from './NavigationMenu'
import type { NavigationTarget } from '../Planner/placeNavigation'
// FE-COMP-NAVMENU-001 to FE-COMP-NAVMENU-007
const TARGETS: NavigationTarget[] = [
{ id: 'google', label: 'Google Maps', url: 'https://maps.example/g' },
{ id: 'waze', label: 'Waze', url: 'https://waze.example/w' },
]
/** A trigger at a chosen viewport position, so the flip can be provoked. */
function anchorAt(top: number, height = 30) {
const el = document.createElement('button')
document.body.appendChild(el)
el.getBoundingClientRect = () => ({
top, bottom: top + height, left: 40, right: 160, width: 120, height,
x: 40, y: top, toJSON: () => ({}),
}) as DOMRect
return el
}
/** jsdom measures everything as 0, so the menu needs a size to be placed. */
function withMenuHeight(height: number) {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
top: 0, bottom: height, left: 0, right: 200, width: 200, height,
x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
}
beforeEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
window.innerHeight = 800
window.innerWidth = 1200
})
describe('NavigationMenu', () => {
it('FE-COMP-NAVMENU-001: lists one entry per target', () => {
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={vi.fn()} />)
expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument()
})
it('FE-COMP-NAVMENU-002: opening a target closes the menu', () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const onClose = vi.fn()
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={onClose} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Waze' }))
expect(open).toHaveBeenCalledWith('https://waze.example/w', '_blank', 'noopener,noreferrer')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-COMP-NAVMENU-003: sits below the trigger when there is room', () => {
withMenuHeight(120)
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={vi.fn()} />)
// Trigger ends at 130, so the menu starts just under it.
expect(screen.getByRole('menu')).toHaveStyle({ top: '136px' })
})
it('FE-COMP-NAVMENU-004: flips above the trigger when the bottom edge is close', () => {
// Trigger ends at 780 with a viewport of 800: a 120px menu would be cut off.
withMenuHeight(120)
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(750)} onClose={vi.fn()} />)
expect(screen.getByRole('menu')).toHaveStyle({ top: '624px' })
})
it('FE-COMP-NAVMENU-005: never leaves the viewport, even when neither side fits', () => {
// Taller than the window: clamped to the top margin rather than negative.
withMenuHeight(900)
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(700)} onClose={vi.fn()} />)
expect(screen.getByRole('menu')).toHaveStyle({ top: '8px' })
})
it('FE-COMP-NAVMENU-006: Escape closes it', () => {
const onClose = vi.fn()
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-COMP-NAVMENU-007: renders nothing without targets', () => {
render(<NavigationMenu targets={[]} anchor={anchorAt(100)} onClose={vi.fn()} />)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,131 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Navigation } from 'lucide-react'
import type { NavigationTarget } from '../Planner/placeNavigation'
import { openNavigationTarget } from '../Planner/placeNavigation'
interface NavigationMenuProps {
targets: NavigationTarget[]
/** The element the menu is anchored to; it is measured, never moved. */
anchor: HTMLElement | null
onClose: () => void
/** Heading above the list. Omitted on desktop, where the trigger says it. */
title?: string
}
const GAP = 6
const EDGE = 8
/**
* The map-app picker.
*
* Portalled rather than nested, because both call sites sit inside panels that
* clip their overflow — a menu rendered in place would be cut off at the panel
* edge. Being in the body means the position has to be computed, and computed
* after the menu has a size: opening near the bottom of the window flips it
* above the trigger instead of letting it run off the screen, and a menu wider
* than the space to its right is pulled back inside.
*/
export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMenuProps) {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Layout effect, not effect: the menu is placed before the browser paints, so
// it never appears at the wrong spot for a frame and jumps.
useLayoutEffect(() => {
const el = ref.current
if (!el || !anchor) return
const place = () => {
const a = anchor.getBoundingClientRect()
const m = el.getBoundingClientRect()
const below = window.innerHeight - a.bottom
const flip = below < m.height + GAP + EDGE && a.top > below
const top = flip ? Math.max(EDGE, a.top - m.height - GAP) : a.bottom + GAP
const left = Math.min(Math.max(EDGE, a.left), window.innerWidth - m.width - EDGE)
setPos({ top, left })
}
place()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
}
}, [anchor, targets.length])
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node)) onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [anchor, onClose])
if (targets.length === 0) return null
return createPortal(
<div
ref={ref}
role="menu"
style={{
position: 'fixed',
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
// Hidden until measured, so the first paint is never in the wrong place.
visibility: pos ? 'visible' : 'hidden',
zIndex: 99999,
minWidth: 186,
maxHeight: `calc(100vh - ${EDGE * 2}px)`,
overflowY: 'auto',
padding: 5,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--border-primary)',
borderRadius: 12,
boxShadow: '0 10px 34px rgba(0,0,0,0.16)',
animation: 'trek-menu-enter 180ms cubic-bezier(0.23, 1, 0.32, 1)',
willChange: 'transform, opacity',
}}
>
{title && (
<div style={{
padding: '5px 10px 7px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--text-faint)',
}}>
{title}
</div>
)}
{targets.map(target => (
<button
key={target.id}
role="menuitem"
onClick={() => { openNavigationTarget(target); onClose() }}
style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 8, border: 'none',
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500,
textAlign: 'left', color: 'var(--text-primary)',
transition: 'background 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
<Navigation size={13} style={{ flexShrink: 0, color: 'var(--text-faint)' }} />
<span>{target.label}</span>
</button>
))}
</div>,
document.body,
)
}
@@ -288,7 +288,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
white canvas behind the transparent frame (same trap .hero-overlay-frame
guards against), which glares in dark mode. */}
<div className="min-h-[120px]">
<PluginFrame pluginId={id} path="settings.html" title={name} className="[color-scheme:light]" />
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
</div>
</MSetCard>
)
@@ -458,6 +458,24 @@ export default function MTripShell({
</div>
)}
{/* A plugin tab is the only one that used to arrive without a name: it is
not in the dock, so nothing was lit up there either, and the screen
gave no clue which plugin was open. Same treatment as the others, from
the tab entry the planner already builds (id, label, icon). */}
{trTab.startsWith('plugin:') && (() => {
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
if (!tab) return null
const Icon = tab.icon
return (
<div className="pointer-events-none absolute left-[52px] right-[52px] top-1/2 flex -translate-y-1/2 items-center justify-center gap-[7px]">
<div className="flex min-w-0 items-center gap-[7px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[13px] py-[7px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
{Icon && <Icon size={14} strokeWidth={2} className="flex-none text-m-muted" />}
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{tab.label}</span>
</div>
</div>
)
})()}
{trTab === 'plan' ? (
<MIconBtn
ariaLabel={view === 'plan' ? t('mobileTrip.mapView') : t('mobileTrip.listView')}
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Trash2, Wallet } from 'lucide-react'
import { Check, ChevronDown, Plus, Trash2, Wallet } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import CustomSelect from '../../../../components/shared/CustomSelect'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { NumericInput } from '../../../../components/shared/NumericInput'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { useTranslation } from '../../../../i18n'
import { useToast } from '../../../../components/shared/Toast'
import { useTripStore } from '../../../../store/tripStore'
@@ -12,7 +12,7 @@ import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { calculateTicketShares, splitEqualShares, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
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'
import GuestBadge from '../../../../components/shared/GuestBadge'
@@ -69,6 +69,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
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 [total, setTotal] = useState<string>(() => {
@@ -96,7 +98,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) return 'ticket'
if (hasTicketSplit(editing)) return 'ticket'
if (editing && editing.members && editing.members.length > 0) {
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
return hasCustom ? 'custom' : 'equally'
@@ -104,22 +106,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: { name: string; price: number | string; parts?: number[] }) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set<number>(item.parts || []),
}))
} catch {
return []
}
}
return []
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
@@ -268,9 +255,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
member_ids: [...participants],
expense_date: day || null,
total_price: totalNum,
note: splitMode === 'ticket'
? 'TICKETJSON:' + JSON.stringify({ items: ticketItems.map(item => ({ name: item.name, price: item.price, parts: [...item.participants] })) })
: null,
note: note.trim() || null,
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
@@ -316,11 +302,6 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</span>
)
const pillCls = (active: boolean) =>
`flex items-center gap-[5px] rounded-full px-[11px] py-[6px] text-[0.71875rem] font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
}`
const submitLabel = saving ? t('common.saving') : editing ? t('common.save') : t('common.add')
return (
@@ -391,19 +372,45 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</div>
)}
{/* CATEGORY */}
{/* CATEGORY — a dropdown rather than eleven pills, which took a third of
the sheet and pushed the split below the fold. Same shape as the
category filter on the tab behind it. */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('costs.category')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon
return (
<button key={c.key} type="button" onClick={() => setCat(c.key)} aria-pressed={cat === c.key} className={pillCls(cat === c.key)}>
<Icon size={12} strokeWidth={2.2} style={{ color: c.color }} />
{t(c.labelKey)}
</button>
)
})}
</div>
<button
type="button"
aria-expanded={catOpen}
onClick={() => setCatOpen(v => !v)}
className="flex w-full items-center gap-[10px] overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-card px-[13px] py-[11px] text-left"
>
{(() => {
const meta = catMeta(cat)
const Icon = meta.Icon
return <Icon size={15} strokeWidth={2} style={{ color: meta.color }} className="flex-none" />
})()}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{t(catMeta(cat).labelKey)}</span>
<ChevronDown size={14} strokeWidth={2} className={`flex-none text-m-faint transition-transform duration-200 ${catOpen ? 'rotate-180' : ''}`} />
</button>
{catOpen && (
<div className="mt-[6px] max-h-[240px] overflow-y-auto overscroll-contain rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon
const on = cat === c.key
return (
<button
key={c.key}
type="button"
aria-pressed={on}
onClick={() => { setCat(c.key); setCatOpen(false) }}
className="flex w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[11px] text-left last:border-b-0"
>
<Icon size={14} strokeWidth={2} style={{ color: c.color }} className="flex-none" />
<span className={`flex-1 text-[0.78125rem] ${on ? 'font-bold text-m-ink' : 'font-medium text-m-muted'}`}>{t(c.labelKey)}</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-m-ink" />}
</button>
)
})}
</div>
)}
{/* WHO PAID */}
<div className="mb-[6px] mt-3 flex items-center justify-between">
@@ -609,6 +616,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</div>
</>
)}
{/* NOTE — last, because it is the one field that is never required. The
room for it came from folding the category pills into a dropdown. */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('costs.note')}</Eyebrow>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={2}
maxLength={NOTE_MAX}
placeholder={t('costs.notePlaceholder')}
className={FIELD_AREA_CLS}
/>
</div>
<FormSheetFooter
@@ -18,7 +18,8 @@ import TrackColorPicker from '../../../../components/shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors'
import { avatarSrc } from '../../../../utils/avatarSrc'
import { openFile } from '../../../../utils/fileDownload'
import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps'
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
import type { Assignment, Day, TripMember } from '../../../../types'
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
@@ -43,6 +44,8 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const [dayPickerOpen, setDayPickerOpen] = useState(false)
const [participantPickerOpen, setParticipantPickerOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [navOpen, setNavOpen] = useState(false)
const navBtnRef = useRef<HTMLButtonElement>(null)
const [imgBusy, setImgBusy] = useState(false)
const [colorPickerOpen, setColorPickerOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
@@ -199,9 +202,13 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
planner.setFitKey(k => k + 1)
}
// One app installed-and-offered means no picker: the tap opens it, which is
// what this button has always done. The sheet only appears when there is an
// actual choice to make.
const navTargets = getNavigationTargets(place)
const openDirections = () => {
const url = getGoogleMapsUrlForPlace(place)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
if (navTargets.length === 1) openNavigationTarget(navTargets[0])
else if (navTargets.length > 1) setNavOpen(true)
}
return (
@@ -496,10 +503,24 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<Bookmark size={15} strokeWidth={2} />
</ActionCircle>
)}
{place.lat != null && place.lng != null && (
<ActionCircle onClick={openDirections} label={t('inspector.google')}>
<Navigation size={15} strokeWidth={2} />
</ActionCircle>
{navTargets.length > 0 && (
<>
<ActionCircle
ref={navBtnRef}
onClick={openDirections}
label={navTargets.length === 1 ? navTargets[0].label : t('inspector.navigation')}
>
<Navigation size={15} strokeWidth={2} />
</ActionCircle>
{navOpen && (
<NavigationMenu
targets={navTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
title={t('inspector.openWith')}
/>
)}
</>
)}
{place.lat != null && place.lng != null && (
<ActionCircle onClick={showOnMap} label={t('mobileTrip.showOnMap')}>
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, Ref } from 'react'
import { X } from 'lucide-react'
import MIconBtn from '../../../components/MIconBtn'
import { formatTime } from '../../../../utils/formatters'
@@ -88,12 +88,15 @@ interface ActionCircleProps {
danger?: boolean
className?: string
children: ReactNode
/** For callers that anchor a popup to the button. React 19 passes it through. */
ref?: Ref<HTMLButtonElement>
}
/** 38px round action button of the inspector footer rows. */
export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children }: ActionCircleProps) {
export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children, ref }: ActionCircleProps) {
return (
<button
ref={ref}
type="button"
onClick={onClick}
aria-label={label}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -41,7 +41,14 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
return (
<TabScroller>
{showTravelerFilter && (
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
<TravelerFilterRow
members={planner.tripMembers}
active={travelerFilter}
onToggle={toggleTravelerFilter}
onClear={() => setTravelerFilter(new Set())}
label={t('reservations.travelers.label')}
allLabel={t('common.all')}
/>
)}
{sections.length === 0 ? (
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-8 py-10 text-center">
@@ -52,7 +59,7 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
<div key={section.id}>
<SectionHeader
label={section.label}
count={<CountPill>{section.rows.length}</CountPill>}
count={section.rows.length}
open={!collapsed[section.id]}
onToggle={() => toggle(section.id)}
/>
@@ -9,7 +9,7 @@ import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { CountPill, SectionHeader, TabScroller } from './tabChrome'
import { SectionHeader, TabScroller } from './tabChrome'
import {
formatPollCountdown,
hasUserVoted,
@@ -191,7 +191,7 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
{active.length > 0 && (
<SectionHeader
label={t('collab.polls.closedSection')}
count={<CountPill>{closed.length}</CountPill>}
count={closed.length}
open={!collapsedClosed}
onToggle={() => setCollapsedClosed(v => !v)}
/>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AlertCircle, ArrowDown, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
Layers, Pencil, Plus, Trash2,
Layers, Pencil, Plus, StickyNote, Trash2,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useAuthStore } from '../../../../store/authStore'
@@ -12,6 +12,7 @@ import { formatMoney } from '../../../../utils/formatters'
import { downloadBlob } from '../../../../utils/fileDownload'
import { budgetApi } from '../../../../api/client'
import MCostSheet from '../sheets/MCostSheet'
import { readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import MConfirmSheet from '../../settings/MConfirmSheet'
import MSheet from '../../../components/MSheet'
@@ -536,6 +537,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
const unfinished = isUnfinished(item, ctx)
const borderColor = tint(meta.color, 0.55)
const members = item.members || []
const note = readUserNote(item)
// A phone row has no width to spare for a note, so it stays folded away and
// the card itself is the handle — no extra control, no extra height.
const [noteOpen, setNoteOpen] = useState(false)
return (
<div className="mt-2 flex items-center gap-[6px]">
@@ -590,6 +595,25 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
{formatMoney(total, base, locale)}
</span>
</div>
{note && (
<button
type="button"
aria-expanded={noteOpen}
onClick={() => setNoteOpen(v => !v)}
className="mt-[7px] flex w-full items-center gap-[7px] rounded-xl bg-[color:var(--m-ic)] px-[9px] py-[6px] text-left"
>
<StickyNote size={11} strokeWidth={2} className="flex-none text-m-faint" />
<span className={`min-w-0 flex-1 text-[0.6875rem] leading-[1.5] text-m-muted ${noteOpen ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'truncate'}`}>
{note}
</span>
<ChevronDown
size={12}
strokeWidth={2}
className={`flex-none text-m-faint transition-transform duration-200 ${noteOpen ? 'rotate-180' : ''}`}
/>
</button>
)}
</div>
{canEdit && (
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
TRANSPORT_TYPE_COLOR,
@@ -46,7 +46,14 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
return (
<TabScroller>
{showTravelerFilter && (
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
<TravelerFilterRow
members={planner.tripMembers}
active={travelerFilter}
onToggle={toggleTravelerFilter}
onClear={() => setTravelerFilter(new Set())}
label={t('reservations.travelers.label')}
allLabel={t('common.all')}
/>
)}
{sections.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center px-8 py-10 text-center">
@@ -57,7 +64,7 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
<div key={section.id}>
<SectionHeader
label={section.label}
count={<CountPill>{section.rows.length}</CountPill>}
count={section.rows.length}
open={!collapsed[section.id]}
onToggle={() => toggle(section.id)}
/>
@@ -18,12 +18,27 @@ import PluginFrame from '../../../../components/Plugins/PluginFrame'
*/
export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProps) {
// Trip-page plugin tab — the same sandboxed frame the desktop planner mounts,
// filling the panel between the top chrome and the dock. The bottom padding
// mirrors TabScroller's safe-area clearance so the frame never hides under it.
// between the floating top controls and the dock. Both clearances come from the
// variables TabScroller uses, so the frame cannot drift away from the native
// tabs: the top one kept the plugin's first row under the z-42 back button, and
// the bottom one was a copy of 84px that ignored the safe area on the devices
// that have one. The frame also states its surface, so the plugin knows it is
// expected to fill and scroll itself rather than report a height.
//
// color-scheme is pinned for the same reason both settings mounts pin it:
// Chromium paints a white canvas behind a transparent frame otherwise.
if (tab.startsWith('plugin:')) {
return (
<div className="absolute inset-0" style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 84px)' }}>
<PluginFrame pluginId={tab.slice('plugin:'.length)} tripId={planner.tripId != null ? String(planner.tripId) : null} fill className="h-full w-full" />
<div
className="absolute inset-0 pt-[calc(var(--m-safe-top,12px)+58px)] pb-[calc(var(--bottom-nav-h,84px)+22px)]"
>
<PluginFrame
pluginId={tab.slice('plugin:'.length)}
tripId={planner.tripId != null ? String(planner.tripId) : null}
fill
surface="trip-tab"
className="h-full w-full [color-scheme:light]"
/>
</div>
)
}
@@ -1,5 +1,5 @@
import type { CostCategory } from '@trek/shared'
import { splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { readUserNote, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
import { currencyDecimals } from '../../../../utils/formatters'
import type { BudgetItem } from '../../../../types'
@@ -233,8 +233,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
for (const e of sorted) {
const cur = currencyOf(e, opts.ctx)
// Ticket notes carry the itemized-receipt JSON, not a human note.
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
const note = readUserNote(e)
rows.push(
[
esc(fmtDate(e.expense_date || '')),
@@ -158,31 +158,65 @@ export function TravelerAvatars({ travelers, label }: {
)
}
/** Avatar toggle row to filter the list by assigned traveler (#1517/#1557). */
export function TravelerFilterRow({ members, active, onToggle, label }: {
/**
* Avatar toggle row to filter the list by assigned traveler (#1517/#1557).
*
* The avatars used to sit on their own between the toolbar and the first
* section, which read as decoration rather than as a control: nothing said what
* they were, and with a filter on there was no way back other than tapping the
* same avatar again. It is a labelled bar now, closed off by the same divider
* the rows below use, with an explicit way to clear the filter.
*
* Its horizontal padding follows SectionHeader rather than the sheet's usual 18,
* so the label starts on the same line as the section titles right below it.
*/
export function TravelerFilterRow({ members, active, onToggle, onClear, label, allLabel }: {
members: { id: number; username: string; avatar_url?: string | null }[]
active: Set<number>
onToggle: (id: number) => void
onClear?: () => void
label: string
/** Resets the filter. Only rendered while one is active. */
allLabel?: string
}) {
const filtering = active.size > 0
return (
<div className="flex flex-wrap items-center gap-1.5 px-[18px] pb-2 pt-1" aria-label={label}>
{members.map(m => {
const on = active.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
title={m.username}
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || active.size === 0 ? 'opacity-100' : 'opacity-40'}`}
>
{m.avatar_url
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
: m.username?.[0]?.toUpperCase()}
</button>
)
})}
<div
className="mb-[2px] flex items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[2px] pb-[9px] pt-[2px]"
role="group"
aria-label={label}
>
<span className="flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{label}
</span>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{members.map(m => {
const on = active.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
title={m.username}
aria-pressed={on}
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || !filtering ? 'opacity-100' : 'opacity-40'}`}
>
{m.avatar_url
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
: m.username?.[0]?.toUpperCase()}
</button>
)
})}
</div>
{filtering && allLabel && onClear && (
<button
type="button"
onClick={onClear}
className="flex-none rounded-full border border-[color:var(--m-rowbr)] px-[9px] py-[3px] font-geist text-[0.625rem] font-bold uppercase tracking-[.06em] text-m-muted"
>
{allLabel}
</button>
)}
</div>
)
}
+19 -1
View File
@@ -69,6 +69,7 @@ function makeHook(overrides: Record<string, unknown> = {}) {
places: [{ id: 10 }],
visiblePlaces: [{ id: 10 }],
mappable: [{ id: 10 }],
hasMappable: true,
members: [],
incomingInvites: [],
counts: { all: 1, idea: 0, want: 1, visited: 0 },
@@ -124,8 +125,25 @@ describe('CollectionsPage — Add place button reachability (#1485)', () => {
})
it('shows the Add button on an empty collection', () => {
mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [] }))
mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [], hasMappable: false }))
render(<CollectionsPage />)
expect(screen.getAllByRole('button', { name: 'collections.addPlace' })).toHaveLength(1)
})
})
describe('CollectionsPage — the map survives a filter that matches nothing', () => {
it('keeps the map when a label filter empties the list', () => {
// Clicking a label with no places behind it left visiblePlaces empty, and
// the layout hung off exactly that: the map was torn out and the page
// reflowed to full width. A filter should empty the map, not remove it.
mockUseCollections.mockReturnValue(makeHook({ visiblePlaces: [], mappable: [], hasMappable: true }))
render(<CollectionsPage />)
expect(screen.getByTestId('map')).toBeInTheDocument()
})
it('still hides the map for a list with nothing mappable in it at all', () => {
mockUseCollections.mockReturnValue(makeHook({ mappable: [], hasMappable: false }))
render(<CollectionsPage />)
expect(screen.queryByTestId('map')).toBeNull()
})
})
+10 -5
View File
@@ -59,8 +59,8 @@ function CollectionsPageDesktop(): React.ReactElement {
// + detail come into view alongside the map.
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
const desktopSplit = c.isWide && mappable.length > 0
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
const desktopSplit = c.isWide && c.hasMappable
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit
@@ -79,6 +79,11 @@ function CollectionsPageDesktop(): React.ReactElement {
)
const mapPanel = (overlay: boolean) => (
<CollectionMapPanel
labelOptions={isRealList ? c.labelOptions : []}
labelFilter={c.labelFilter}
onLabelFilter={isRealList ? c.setLabelFilter : undefined}
canManageLabels={canManageLabels}
onManageLabels={() => c.setShowLabelManager(true)}
places={mappable}
selectedPlaceId={c.selectedPlaceId}
onSelect={onMapSelect}
@@ -109,7 +114,7 @@ function CollectionsPageDesktop(): React.ReactElement {
onSortMode={c.setSortMode}
canAddPlace={canAddPlace}
onAddPlace={() => c.setShowAddPlace(true)}
showLabels={isRealList}
showLabels={isRealList && !mapShown}
labelOptions={c.labelOptions}
labelFilter={c.labelFilter}
onLabelFilter={c.setLabelFilter}
@@ -151,7 +156,7 @@ function CollectionsPageDesktop(): React.ReactElement {
<div className="col-split-map">{mapPanel(true)}</div>
</div>
)
} else if (c.view === 'map' && mappable.length > 0) {
} else if (c.view === 'map' && c.hasMappable) {
body = <div className="col-mapwrap">{mapPanel(false)}</div>
} else {
body = listColumn
@@ -212,7 +217,7 @@ function CollectionsPageDesktop(): React.ReactElement {
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
<Bookmark size={15} /> {t('collections.title')}
</button>
{!c.isWide && mappable.length > 0 && (
{!c.isWide && c.hasMappable && (
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
<button type="button" aria-pressed={c.view === 'list'} onClick={() => c.setView('list')} aria-label={t('collections.view.list')} title={t('collections.view.list')} className={c.view === 'list' ? 'on' : ''}>
<ListIcon size={16} />
@@ -59,9 +59,11 @@ vi.mock('../components/Collections/CollectionFilterBar', () => ({
}))
vi.mock('../components/Collections/CollectionMapPanel', () => ({
default: (p: { overlay: boolean; places: { id: number }[]; onSelect: (id: number) => void; onDeselect: () => void; onToggleView: () => void; onSearch: (v: string) => void }) => (
default: (p: { overlay: boolean; places: { id: number }[]; onSelect: (id: number) => void; onDeselect: () => void; onToggleView: () => void; onSearch: (v: string) => void; onLabelFilter?: (ids: number[]) => void; onManageLabels?: () => void }) => (
<div data-testid={p.overlay ? 'map-overlay' : 'map-plain'}>
<span data-testid="map-count">{p.places.length}</span>
<span data-testid="map-has-labels">{String(p.onLabelFilter != null)}</span>
{p.onManageLabels && <button type="button" onClick={p.onManageLabels}>map-labels</button>}
<button type="button" onClick={() => p.onSelect(10)}>map-select</button>
<button type="button" onClick={p.onDeselect}>map-deselect</button>
<button type="button" onClick={p.onToggleView}>map-toggle</button>
@@ -206,6 +208,7 @@ function makeHook(overrides: Hook = {}): Hook {
places: [{ id: 10 }],
visiblePlaces: [{ id: 10 }],
mappable: [{ id: 10 }],
hasMappable: true,
members: [],
incomingInvites: [],
counts: { all: 1, idea: 0, want: 1, visited: 0 },
@@ -340,7 +343,7 @@ describe('CollectionsPage — shell', () => {
})
it('FE-PAGE-COLLPAGE-007: shows a spinner while the first places load', () => {
renderPage({ places: [], visiblePlaces: [], mappable: [], placesLoading: true })
renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, placesLoading: true })
expect(document.querySelector('.col-spinner')).not.toBeNull()
expect(screen.queryByTestId('list')).toBeNull()
})
@@ -348,7 +351,7 @@ describe('CollectionsPage — shell', () => {
describe('CollectionsPage — body branches', () => {
it('FE-PAGE-COLLPAGE-008: an empty list shows the empty state plus the add CTA', () => {
const hook = renderPage({ places: [], visiblePlaces: [], mappable: [] })
const hook = renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false })
expect(screen.getByTestId('empty-collections')).toHaveTextContent('collections.empty.title')
fireEvent.click(screen.getByRole('button', { name: /collections.addPlace/ }))
@@ -356,7 +359,7 @@ describe('CollectionsPage — body branches', () => {
})
it('FE-PAGE-COLLPAGE-009: a viewer on an empty list gets no add CTA', () => {
renderPage({ places: [], visiblePlaces: [], mappable: [], canEdit: false })
renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, canEdit: false })
expect(screen.getByTestId('empty-collections')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /collections.addPlace/ })).toBeNull()
})
@@ -384,13 +387,13 @@ describe('CollectionsPage — body branches', () => {
})
it('FE-PAGE-COLLPAGE-013: places without coordinates keep the list column even on a wide layout', () => {
renderPage({ mappable: [] })
renderPage({ mappable: [], hasMappable: false })
expect(screen.getByTestId('list')).toBeInTheDocument()
expect(screen.queryByTestId('map-overlay')).toBeNull()
})
it('FE-PAGE-COLLPAGE-014: a filter that matches nothing keeps the filter bar and shows the no-match state', () => {
renderPage({ visiblePlaces: [], mappable: [] })
renderPage({ visiblePlaces: [], mappable: [], hasMappable: false })
expect(screen.getByTestId('filterbar')).toBeInTheDocument()
expect(screen.getByTestId('empty-search')).toHaveTextContent('collections.empty.noMatchTitle')
expect(screen.queryByTestId('list')).toBeNull()
@@ -555,20 +558,32 @@ describe('CollectionsPage — child wiring', () => {
expect(hook.setSelectedPlaceId).not.toHaveBeenCalled()
})
it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place, label management and select mode', () => {
it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place and select mode, the map takes the labels', () => {
const hook = renderPage()
expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true')
// Labels ride in the map's top bar while a map is on screen, so the filter
// row is told not to draw them a second time.
expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|true|true')
expect(screen.getByTestId('map-has-labels')).toHaveTextContent('true')
fireEvent.click(screen.getByText('filter-add'))
expect(hook.setShowAddPlace).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('filter-labels'))
fireEvent.click(screen.getByText('map-labels'))
expect(hook.setShowLabelManager).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('filter-select'))
expect(hook.setSelectMode).toHaveBeenCalledWith(true)
})
it('FE-PAGE-COLLPAGE-030b: without a map the filter row keeps the labels, so they stay reachable', () => {
const hook = renderPage({ mappable: [], hasMappable: false })
expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true')
expect(screen.queryByTestId('map-overlay')).toBeNull()
fireEvent.click(screen.getByText('filter-labels'))
expect(hook.setShowLabelManager).toHaveBeenCalledWith(true)
})
it('FE-PAGE-COLLPAGE-031: the All-saved union has no labels and no add button in the filter row', () => {
renderPage({ isAllSaved: true, activeId: 'all', activeCollection: null })
expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|false|true')
+1 -1
View File
@@ -419,7 +419,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
{heroPlugins.length > 0 && (
<div className="hero-pass-overlay" aria-hidden="true">
{heroPlugins.map(p => (
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} className="hero-overlay-frame" />
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} surface="dashboard-widget" className="hero-overlay-frame" />
))}
</div>
)}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function PluginPage(): React.ReactElement {
return (
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
<PluginFrame pluginId={pluginId} title={plugin?.name} fill className="w-full h-full" />
<PluginFrame pluginId={pluginId} title={plugin?.name} fill surface="plugin-page" className="w-full h-full" />
</div>
</PageShell>
)
+1 -1
View File
@@ -784,7 +784,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill className="w-full h-full" />
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill surface="trip-tab" className="w-full h-full" />
</div>
)}
</div>
@@ -177,6 +177,11 @@ export function useCollections() {
// Stable reference so the map doesn't tear down + rebuild every marker on each
// unrelated re-render (which would swallow marker clicks mid-rebuild).
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
// Whether this list has anything mappable AT ALL, filters aside. The layout
// hangs off this rather than off `mappable`: a label with no places left the
// filtered set empty, which tore the map out and reflowed the page to full
// width. The filter should empty the map, not remove it.
const hasMappable = useMemo(() => mappablePlaces(places).length > 0, [places])
const counts = useMemo(() => statusCounts(places), [places])
// ── Handlers ────────────────────────────────────────────────────────
@@ -385,7 +390,7 @@ export function useCollections() {
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
myRole, canEdit, canDelete,
canShare, shareMemberCount,
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, ratingFilter, sortMode, search, selectedPlaceId, selectMode, selectedIds,
labels, labelFilter, labelOptions,
loading, placesLoading,
+54 -10
View File
@@ -274,9 +274,31 @@
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
/* Labels are their own axis of filtering, so they read as one tray rather than
as more loose buttons: a recessed strip, led by a tag glyph, holding chips
that share the filter buttons' radius and type. The chips carry no border of
their own — a frame inside a frame is what made this look like a pile. */
.trek-dash .col-labelfilter {
display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px;
min-height: var(--col-ctl-h); padding: 3px 6px 3px 9px; border-radius: 11px;
background: color-mix(in oklch, var(--surface-2) 70%, transparent);
border: 1px solid var(--line-2);
}
.trek-dash .col-labelfilter-lead { color: var(--ink-3); flex-shrink: 0; margin-right: 2px; }
/* On the map the tray joins the floating controls, so it takes their glass,
their radius and their shadow instead of the row's flat surface. */
.trek-dash .col-labelfilter.on-map {
pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md);
background: color-mix(in oklch, var(--surface) 92%, transparent);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
scrollbar-width: none;
}
.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; }
.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; }
.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
@@ -284,14 +306,35 @@
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
/* ----------------- filter bar (status + category dropdowns) ----------------- */
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
/* One control height for the whole page: the filter row and the buttons floating
over the map are read as a single band when the page is scrolled to the top,
so they share a height and a top offset instead of each having their own. */
.trek-dash { --col-ctl-h: 38px; }
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
/* Beside the map, the row starts where the map's own top bar does. */
.trek-dash .col-split-list .col-filterbar { margin-top: 12px; }
.trek-dash .col-filter { position: relative; }
/* Icon-only add-place button: square it off so it matches the dropdowns' height. */
.trek-dash .col-filter-add { padding: 7px; }
/* Square icon-only buttons: as wide as they are tall, so they sit in the row
without being the one element that is a few pixels off. */
.trek-dash .col-filter-add,
.trek-dash .col-filter-icon { width: var(--col-ctl-h); padding: 0; justify-content: center; }
/* Manage/add labels sits inside the label tray, so it stays quiet until hovered. */
.trek-dash .col-filter-addlabel {
display: inline-flex; align-items: center; justify-content: center;
padding: 5px; border-radius: 8px; color: var(--ink-3);
background: transparent; border: 1px solid transparent;
transition: background .12s, color .12s;
}
.trek-dash .col-filter-addlabel:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-filter-select { margin-left: auto; }
.trek-dash .col-filter-select.open { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-select.open:hover { background: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
/* `.on`, not `.open`: on a dropdown "open" means the panel is down and the text
stays dark, and that rule sits later in this file, so sharing the name left
the active select button drawing a dark icon on its dark accent fill. The
other toggles in this page (col-map-btn, col-iconbtn) already use `.on`. */
.trek-dash .col-filter-select.on { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-select.on:hover { background: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; height: var(--col-ctl-h); padding: 0 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); }
.trek-dash .col-filter-btn .col-filter-chev { color: var(--ink-3); transition: transform .15s; }
@@ -461,9 +504,10 @@
.trek-dash .col-search { flex: 1; }
.trek-dash .col-mapwrap { height: calc(100dvh - 260px); min-height: 320px; }
/* bigger touch targets on phones (visuals unchanged on desktop) */
/* bigger touch targets on phones (visuals unchanged on desktop). Raising the
shared height keeps the square icon buttons square. */
.trek-dash { --col-ctl-h: 40px; }
.trek-dash .col-viewseg button { width: 44px; height: 38px; }
.trek-dash .col-filter-btn { min-height: 40px; }
.trek-dash .col-filter-opt { min-height: 40px; }
.trek-dash .col-selbar-btn { min-height: 40px; }
.trek-dash .col-detail-close { width: 40px; height: 40px; }
@@ -64,6 +64,8 @@ function fillBasics(name: string, total: string) {
fireEvent.change(totalField(), { target: { value: total } })
}
const CAT_LABELS = ['Accommodation', 'Food & drink', 'Groceries', 'Transport', 'Flights', 'Activities', 'Sightseeing', 'Shopping', 'Fees & tickets', 'Health', 'Tips', 'Other']
describe('MCostSheet', () => {
beforeEach(() => {
resetAllStores()
@@ -84,11 +86,19 @@ describe('MCostSheet', () => {
delete (window as unknown as { __addToast?: unknown }).__addToast
})
// The category moved from a row of eleven pills to a dropdown: the trigger
// shows the current one, and the options only exist while it is open.
const catTrigger = () => screen.getByRole('button', { name: new RegExp(`^(${CAT_LABELS.join('|')})$`) })
const chooseCategory = (label: string) => {
fireEvent.click(catTrigger())
fireEvent.click(screen.getByRole('button', { name: label, pressed: false }))
}
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('')
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'true')
expect(catTrigger()).toHaveTextContent('Food & drink')
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
expect(submit()).toBeDisabled()
@@ -115,15 +125,48 @@ describe('MCostSheet', () => {
expense_date: TODAY,
total_price: 85.5,
note: null,
ticket_json: null,
})
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
})
it('FE-MOB-COSTSH-020: the category dropdown opens, marks the current pick and closes on choose (#1658)', () => {
renderSheet()
expect(screen.queryByRole('button', { name: 'Groceries' })).toBeNull()
fireEvent.click(catTrigger())
expect(screen.getByRole('button', { name: 'Food & drink', pressed: true })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Groceries', pressed: false }))
expect(catTrigger()).toHaveTextContent('Groceries')
expect(screen.queryByRole('button', { name: 'Sightseeing' })).toBeNull()
})
it('FE-MOB-COSTSH-021: a note typed on the phone is saved with the expense (#1658)', async () => {
renderSheet()
fillBasics('Pharmacy', '18')
fireEvent.change(screen.getByPlaceholderText(/what it covered/i), { target: { value: 'Ben was ill, he pays this back' } })
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
note: 'Ben was ill, he pays this back',
})))
})
it('FE-MOB-COSTSH-022: an existing note is loaded for editing, and a receipt blob never is (#1658)', () => {
const { unmount } = renderSheet({ editing: buildBudgetItem({ id: 5, name: 'Chemist', note: 'reimburse from the kitty' }) })
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('reimburse from the kitty')
unmount()
renderSheet({ editing: buildBudgetItem({ id: 6, name: 'Market', note: 'TICKETJSON:{"items":[]}' }) })
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('')
})
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
expect(nameField()).toHaveValue('Ryokan')
expect(totalField()).toHaveValue('240')
expect(screen.getByRole('button', { name: 'Accommodation' })).toHaveAttribute('aria-pressed', 'true')
expect(catTrigger()).toHaveTextContent('Accommodation')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
@@ -167,9 +210,8 @@ describe('MCostSheet', () => {
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
renderSheet()
fillBasics('Metro pass', '20')
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
expect(screen.getByRole('button', { name: 'Transport' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'false')
chooseCategory('Transport')
expect(catTrigger()).toHaveTextContent('Transport')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
@@ -323,7 +365,7 @@ describe('MCostSheet', () => {
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
total_price: 20,
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
ticket_json: '{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
}))
})
@@ -369,7 +411,7 @@ describe('MCostSheet', () => {
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('Flight to Osaka')
expect(screen.getByRole('button', { name: 'Flights' })).toHaveAttribute('aria-pressed', 'true')
expect(catTrigger()).toHaveTextContent('Flights')
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
@@ -368,9 +368,14 @@ describe('MPlaceSheet', () => {
const shell = makeShell({ trTab: 'dateien', view: 'plan' })
const { planner } = renderSheet(makePlanner(), shell)
fireEvent.click(screen.getByRole('button', { name: 'Google Maps' }))
// A place with coordinates reaches more than one map app, so the circle
// opens the picker instead of guessing which one was meant.
fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' }))
// Name and address rather than bare coordinates (#1278): the place has both,
// so Google opens the entry instead of an unlabelled pin.
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer',
'https://www.google.com/maps/search/?api=1&query=Kunsthistorisches%20Museum%2C%20Maria-Theresien-Platz', '_blank', 'noopener,noreferrer',
)
fireEvent.click(screen.getByRole('button', { name: 'Open Website' }))
@@ -405,7 +410,7 @@ describe('MPlaceSheet', () => {
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument()
// Read-only members still get the navigation actions.
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
})
it('FE-MOB-PLSH-026: collapses the track colour picker again when the sheet closes', () => {
@@ -20,8 +20,8 @@ vi.mock('../../../../src/mobile/screens/trip/tabs/MFilesTab', () => ({ default:
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabTab', () => ({ default: stub('collab') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MListsTab', () => ({ default: stub('lists') }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, fill }: { pluginId: string; tripId: string | null; fill?: boolean }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} />
default: ({ pluginId, tripId, fill, surface }: { pluginId: string; tripId: string | null; fill?: boolean; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} data-surface={surface} />
),
}))
@@ -71,8 +71,20 @@ describe('MTripTabPanel', () => {
expect(frame).toHaveAttribute('data-plugin', 'trip-todos')
expect(frame).toHaveAttribute('data-trip', '1')
expect(frame).toHaveAttribute('data-fill', 'true')
// The frame keeps the dock clearance the scroll body uses.
expect((frame.parentElement as HTMLElement).style.paddingBottom).toContain('safe-area-inset-bottom')
// Both clearances come from the variables TabScroller uses, so the frame
// cannot drift away from the native tabs. The top one was missing entirely,
// which put the plugin's first row under the floating back button; the bottom
// one was a hard-coded 84 that ignored the safe area.
const wrapper = frame.parentElement as HTMLElement
expect(wrapper.className).toContain('--m-safe-top')
expect(wrapper.className).toContain('--bottom-nav-h')
})
it('FE-MOB-TABPANEL-007b: tells the plugin which surface it is mounted in', () => {
// A trip tab fills and scrolls itself; a widget reports its height. The plugin
// cannot see the difference from inside the frame.
renderTab('plugin:trip-todos')
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-surface', 'trip-tab')
})
it('FE-MOB-TABPANEL-008: passes a null tripId to the plugin frame before the trip is known', () => {
@@ -148,4 +148,54 @@ describe('tabChrome', () => {
expect(screen.getByTitle('Ada').className).toContain('opacity-100')
expect(screen.getByTitle('Bob').className).toContain('opacity-100')
})
it('FE-MOB-TABCHR-015: TravelerFilterRow names itself, so the avatars are not left unexplained', () => {
render(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }]}
active={new Set()}
onToggle={vi.fn()}
label="Travelers"
/>,
)
expect(screen.getByRole('group', { name: 'Travelers' })).toBeInTheDocument()
expect(screen.getByText('Travelers')).toBeInTheDocument()
})
it('FE-MOB-TABCHR-016: the way out of a filter only appears once one is set', () => {
const onClear = vi.fn()
const { rerender } = render(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }]}
active={new Set()}
onToggle={vi.fn()}
onClear={onClear}
label="Travelers"
allLabel="All"
/>,
)
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument()
rerender(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }]}
active={new Set([1])}
onToggle={vi.fn()}
onClear={onClear}
label="Travelers"
allLabel="All"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'All' }))
expect(onClear).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TABCHR-017: SectionHeader wraps its count exactly once', () => {
// The callers used to pass a <CountPill> into a prop that wraps one itself,
// which put a pill inside a pill and showed two stacked backgrounds.
render(<SectionHeader label="Pending" count={2} open onToggle={vi.fn()} />)
const pill = screen.getByText('2')
expect(pill.querySelector('span')).toBeNull()
expect(pill.parentElement?.tagName).toBe('BUTTON')
})
})
+131
View File
@@ -279,6 +279,125 @@ body.trek-ui {
@media (max-width: 639px) {
.trek-modal-enter { animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1); }
}
/* Mobile layer ------------------------------------------------------------- *
* TREK 4 gives the phone its own design: a translucent, border-led surface over
* a gradient, no drop shadows, and touch feedback rather than hover. The host
* hands the mobile palette (--m-*) down with the rest of the tokens whenever a
* frame is mounted inside the mobile shell, so these rules only restate the
* SHAPE — the colours come from the same variables the native screens use.
*
* Everything here is keyed on [data-form-factor="phone"], which the host sets
* from the viewport it reports. On desktop not a single rule below applies. */
[data-form-factor="phone"] .trek-ui,
[data-form-factor="phone"] body.trek-ui { font-size: 15px; }
/* The card is the workhorse. On the phone it is a translucent tile with a hairline
border and no shadow — a drop shadow over the gradient reads as a sticker. */
[data-form-factor="phone"] .trek-card {
background: var(--m-card, var(--bg-card));
border: 1px solid var(--m-cbr, var(--border-primary));
border-radius: 16px;
box-shadow: none;
padding: 14px;
-webkit-backdrop-filter: blur(30px) saturate(1.8);
backdrop-filter: blur(30px) saturate(1.8);
}
[data-form-factor="phone"] .trek-glass {
background: var(--m-glass, var(--glass-bg));
border-color: var(--m-gbr, var(--glass-border));
border-radius: 20px;
box-shadow: none;
padding: 16px;
}
/* Touch has no hover. The lift on hover never fires on a phone and the pressed
state never existed, so a tap gave no feedback at all. */
[data-form-factor="phone"] .trek-card.trek-interactive:hover,
[data-form-factor="phone"] .trek-glass.trek-interactive:hover { transform: none; box-shadow: none; }
[data-form-factor="phone"] .trek-interactive:active,
[data-form-factor="phone"] .trek-btn:active,
[data-form-factor="phone"] .trek-row:active { transform: scale(.985); opacity: .9; }
[data-form-factor="phone"] .trek-btn:hover { filter: none; }
/* 44px is the smallest target that reliably hits on a phone. */
[data-form-factor="phone"] .trek-btn { min-height: 44px; border-radius: 12px; }
[data-form-factor="phone"] .trek-row { min-height: 48px; }
/* An input below 16px makes iOS Safari zoom the page on focus, and the zoom does
not come back. This is the single most common mobile bug in embedded UI. */
[data-form-factor="phone"] .trek-input,
[data-form-factor="phone"] .trek-select,
[data-form-factor="phone"] .trek-textarea { font-size: 16px; min-height: 44px; border-radius: 12px; }
/* Ink follows the mobile palette where it exists. */
[data-form-factor="phone"] .trek-title { color: var(--m-ink, var(--text-primary)); }
[data-form-factor="phone"] .trek-sub,
[data-form-factor="phone"] .trek-muted { color: var(--m-muted, var(--text-muted)); }
/* A filling surface owns its own scrolling: the host will not scroll for it, and
a height report is discarded. Anchor the body and scroll the content. */
[data-fill] .trek-ui, [data-fill] body.trek-ui { height: 100%; overflow: hidden; }
[data-fill] .trek-scroll {
height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Building blocks the native mobile screens are made of, so a plugin does not
have to reverse-engineer them from screenshots. */
.trek-eyebrow {
font-size: 10px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;
color: var(--m-faint, var(--text-faint));
}
.trek-countpill {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
background: var(--m-ic, var(--bg-tertiary)); color: var(--m-muted, var(--text-muted));
font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.trek-sectionhead {
display: flex; align-items: center; gap: 7px; width: 100%;
margin: 15px 0 2px; padding: 0 2px; background: none; border: 0;
font: inherit; text-align: left; cursor: pointer;
}
.trek-statusdot { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--m-st-neutral, var(--text-faint)); }
.trek-statusdot[data-status="confirmed"] { background: var(--m-st-confirmed, var(--success)); }
.trek-statusdot[data-status="pending"] { background: var(--m-st-pending, var(--warning)); }
.trek-statusdot[data-status="info"] { background: var(--m-st-info, var(--info)); }
.trek-statusdot[data-status="danger"] { background: var(--m-st-danger, var(--danger)); }
.trek-iconbtn {
display: inline-grid; place-items: center; flex: none;
width: 38px; height: 38px; border-radius: 999px; cursor: pointer;
background: var(--m-ic, var(--bg-tertiary));
border: 1px solid var(--m-gbr, var(--border-primary));
color: var(--m-ink, var(--text-primary));
}
.trek-iconbtn:active { transform: scale(.94); }
.trek-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.trek-field > .trek-field-label {
font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
color: var(--m-faint, var(--text-faint));
}
.trek-field > .trek-field-value {
font-size: 13px; font-weight: 600; color: var(--m-ink, var(--text-primary));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trek-segmented {
display: inline-flex; align-items: center; gap: 2px; padding: 3px; border-radius: 999px;
background: var(--m-glass, var(--bg-tertiary));
border: 1px solid var(--m-gbr, var(--border-primary));
}
.trek-segmented > button {
border: 0; background: none; cursor: pointer; font: inherit;
padding: 8px 16px; border-radius: 999px; white-space: nowrap;
font-size: 13px; font-weight: 500; color: var(--m-ink, var(--text-primary));
}
.trek-segmented > button[aria-pressed="true"],
.trek-segmented > button.on {
background: var(--m-act, var(--accent)); color: var(--m-actfg, var(--accent-text)); font-weight: 600;
}
.trek-backdrop-enter { animation: trek-backdrop-enter 180ms var(--trek-ease-quint); }
.trek-toast-enter { animation: trek-toast-enter 260ms var(--trek-ease-quint); will-change: transform, opacity; }
.trek-pie-reveal {
@@ -371,6 +490,18 @@ export const TREK_THEME_JS = `(function () {
if (m.theme) { docEl.setAttribute('data-theme', m.theme); }
if (m.locale) { docEl.setAttribute('lang', m.locale); }
docEl.setAttribute('dir', m.dir === 'rtl' ? 'rtl' : 'ltr');
// Where this frame sits, and in what shape. data-form-factor drives the
// mobile layer of this stylesheet; data-surface lets a plugin tell a
// full-height tab from a widget that reports its height. The insets are the
// space the HOST already keeps clear — exposed so a plugin can align its own
// sticky chrome with the host's, not so it adds padding on top.
var vp = m.viewport || {};
if (vp.formFactor) { docEl.setAttribute('data-form-factor', vp.formFactor); }
if (vp.surface) { docEl.setAttribute('data-surface', vp.surface); }
setFlag('data-fill', vp.fill);
var ins = vp.insets || {};
docEl.style.setProperty('--trek-inset-top', (ins.top || 0) + 'px');
docEl.style.setProperty('--trek-inset-bottom', (ins.bottom || 0) + 'px');
var t = m.tokens || {};
for (var k in t) {
if (Object.prototype.hasOwnProperty.call(t, k) && t[k]) { docEl.style.setProperty(k, t[k]); }
+15
View File
@@ -3841,6 +3841,21 @@ function runMigrations(db: Database.Database): void {
insert.run(key);
}
},
// Free the `note` column on budget items for its actual purpose (#1658).
// The costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which
// means an expense split by receipt can never carry a written note, and
// saving an expense any other way wipes whatever was typed in the budget
// table. The receipt moves to its own column and note becomes text again.
() => {
const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get();
if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT');
db.exec(`
UPDATE budget_items
SET ticket_json = substr(note, 12), note = NULL
WHERE note LIKE 'TICKETJSON:%'
`);
},
];
if (currentVersion < migrations.length) {
+39 -3
View File
@@ -17,6 +17,29 @@ type SettlementRow = {
to_username: string; to_avatar: string | null;
};
/** How the costs UI used to smuggle an itemized receipt through the note field. */
const LEGACY_TICKET_PREFIX = 'TICKETJSON:';
/**
* Keep a written note and an itemized receipt out of each other's way (#1658).
*
* The receipt has its own column since migration 186, but a client that predates
* it still sends the blob as `note`. Such a payload says nothing about the note
* the user typed, so the note is reported as untouched (`undefined`) rather than
* overwritten — an old tab left open must not erase a note written elsewhere.
*
* `undefined` means "caller did not speak about this field"; `null` means "clear it".
*/
export function splitLegacyTicketNote(
note: string | null | undefined,
ticket: string | null | undefined,
): { note: string | null | undefined; ticket: string | null | undefined } {
if (typeof note === 'string' && note.startsWith(LEGACY_TICKET_PREFIX)) {
return { note: undefined, ticket: note.slice(LEGACY_TICKET_PREFIX.length) };
}
return { note, ticket };
}
/**
* Budget domain service — owns the budget SQL (moved from the legacy
* services/budgetService.ts: identical statements, the `||` falsy-coercion
@@ -276,6 +299,7 @@ export class BudgetService {
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
members?: { user_id: number; amount?: number | null }[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
ticket_json?: string | null;
reservation_id?: number | null;
},
) {
@@ -303,8 +327,10 @@ export class BudgetService {
const knownIds = data.member_ids ? this.knownUserIds(data.member_ids) : null;
const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined;
const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json);
const result = this.db.run(
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId,
cat,
data.name,
@@ -313,7 +339,8 @@ export class BudgetService {
data.exchange_rate != null ? data.exchange_rate : 1,
memberIds ? memberIds.length : (data.persons != null ? data.persons : null),
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
note || null,
ticket || null,
sortOrder,
data.expense_date || null,
data.reservation_id != null ? data.reservation_id : null,
@@ -364,12 +391,19 @@ export class BudgetService {
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
members?: { user_id: number; amount?: number | null }[];
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
ticket_json?: string | null;
},
) {
return this.db.transaction(() => {
const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
if (!item) return null;
// An old client sending a receipt in `note` still lands in ticket_json, and
// its note is left untouched rather than clobbered with the receipt blob.
const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json);
const noteTouched = data.note !== undefined && note !== undefined;
const ticketTouched = data.ticket_json !== undefined || ticket !== undefined;
this.db.run(`
UPDATE budget_items SET
category = COALESCE(?, category),
@@ -380,6 +414,7 @@ export class BudgetService {
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
ticket_json = CASE WHEN ? THEN ? ELSE ticket_json END,
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
WHERE id = ?
@@ -391,7 +426,8 @@ export class BudgetService {
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
noteTouched ? 1 : 0, noteTouched ? note : null,
ticketTouched ? 1 : 0, ticketTouched ? ticket : null,
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
id,
+17 -2
View File
@@ -260,6 +260,8 @@ export interface CommonsCandidate {
interface GooglePlaceResult {
id: string;
displayName?: { text: string };
/** OPERATIONAL | CLOSED_TEMPORARILY | CLOSED_PERMANENTLY. Absent on non-business results. */
businessStatus?: string;
formattedAddress?: string;
location?: { latitude: number; longitude: number };
rating?: number;
@@ -806,6 +808,13 @@ export class MapsService {
const tags = el.tags || {};
const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null;
if (!name) continue; // unnamed POIs aren't useful to add to a plan
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
// the selectors above — but plenty keep their original tag and gain a marker
// instead, and those do come back. `opening_hours=closed`/`off` is the same
// statement in the hours field.
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (lat == null || lng == null) continue;
@@ -1404,7 +1413,7 @@ export class MapsService {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
},
body: JSON.stringify(searchBody),
});
@@ -1417,7 +1426,13 @@ export class MapsService {
throw err;
}
const places = (data.places || []).map((p: GooglePlaceResult) => ({
// A place that has shut down for good is never the answer to "where should we
// go" (#1341). Temporarily closed stays: a restaurant on holiday next month is
// still worth planning around. Anything without the field is a non-business
// result (a park, a viewpoint) and is kept.
const places = (data.places || [])
.filter((p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY')
.map((p: GooglePlaceResult) => ({
google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '',
+2
View File
@@ -135,6 +135,8 @@ export interface BudgetItem {
persons?: number | null;
days?: number | null;
note?: string | null;
/** Itemized receipt for a per-item split, as JSON. Its own column since #1658. */
ticket_json?: string | null;
reservation_id?: number | null;
paid_by_user_id?: number | null;
expense_date?: string | null;
@@ -468,6 +468,84 @@ describe('post-fold quirk fixes', () => {
expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed');
});
// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────
it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, {
name: 'Groceries',
total_price: 40,
note: 'Lisa pays half of this back',
ticket_json: '{"items":[{"name":"Bread","price":"3","parts":[1]}]}',
});
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
expect(row.note).toBe('Lisa pays half of this back');
expect(JSON.parse(row.ticket_json!).items).toHaveLength(1);
});
it('BUDGET-SVC-DB-022: an update that omits note leaves the stored one alone', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 20, note: 'split with Ben' });
// What the mobile sheet sends: notes are written on desktop, so it never
// speaks about the field.
budget.updateBudgetItem(item!.id, trip.id, { total_price: 25 });
const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
expect(row.note).toBe('split with Ben');
});
it('BUDGET-SVC-DB-023: an explicit null clears the note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' });
budget.updateBudgetItem(item!.id, trip.id, { note: null });
const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
expect(row.note).toBeNull();
});
it('BUDGET-SVC-DB-024: a pre-#1658 client sending the receipt as a note cannot erase the note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' });
// An old tab, still encoding the receipt into `note`.
budget.updateBudgetItem(item!.id, trip.id, {
note: 'TICKETJSON:{"items":[{"name":"Milk","price":"2","parts":[1]}]}',
});
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
expect(row.note).toBe('reimburse from the kitty');
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Milk');
});
it('BUDGET-SVC-DB-025: the same legacy payload on create lands in the receipt column', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, {
name: 'Market',
total_price: 9,
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"9","parts":[1]}]}',
});
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
expect(row.note).toBeNull();
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Cheese');
});
it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -804,6 +804,25 @@ describe('searchOverpassPois localized names (#1655)', () => {
expect(pois[0].name).toBe('Elephant Obelisk');
});
it('skips POIs tagged as closed for good (#1341)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
elements: [
{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: { name: 'Gone Forever', tourism: 'attraction', disused: 'yes' } },
{ type: 'node', id: 2, lat: 41.9, lon: 12.49, tags: { name: 'Ruin', tourism: 'attraction', abandoned: 'yes' } },
{ type: 'node', id: 3, lat: 41.9, lon: 12.5, tags: { name: 'Shut', tourism: 'attraction', opening_hours: 'closed' } },
{ type: 'node', id: 4, lat: 41.9, lon: 12.51, tags: { name: 'Open For Business', tourism: 'attraction' } },
],
}),
}),
);
const { pois } = await svc.searchOverpassPois('sights', bbox(5), 'en-US');
expect(pois.map((p: any) => p.name)).toEqual(['Open For Business']);
});
it('falls back to the native name when no localized tag exists', async () => {
stubOverpass({ name: tags.name, tourism: 'attraction' });
const { pois } = await svc.searchOverpassPois('sights', bbox(4), 'fr-FR');
@@ -1162,6 +1181,27 @@ describe('searchPlaces (fetch stubbed)', () => {
expect(place.phone).toBeNull();
});
it('MAPS-183: drops permanently closed Google results and keeps the rest (#1341)', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
places: [
{ id: 'gone', displayName: { text: 'Shuttered Bistro' }, businessStatus: 'CLOSED_PERMANENTLY' },
{ id: 'holiday', displayName: { text: 'Winter Break Cafe' }, businessStatus: 'CLOSED_TEMPORARILY' },
{ id: 'open', displayName: { text: 'Still Trading' }, businessStatus: 'OPERATIONAL' },
{ id: 'park', displayName: { text: 'City Park' } },
],
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await svc.searchPlaces(1, 'bistro');
expect(result.places.map((p: any) => p.google_place_id)).toEqual(['holiday', 'open', 'park']);
// The field has to be requested or it never arrives to filter on.
const mask = (fetchMock.mock.calls[0][1].headers as Record<string, string>)['X-Goog-FieldMask'];
expect(mask).toContain('places.businessStatus');
});
it('MAPS-107: keeps 0 coordinates (equator / prime meridian) instead of nulling them', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
+4
View File
@@ -113,6 +113,8 @@ export const budgetItemSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
/** Itemized receipt behind a per-item split, as JSON. Its own column since #1658. */
ticket_json: z.string().nullable().optional(),
reservation_id: z.number().nullable().optional(),
paid_by_user_id: z.number().nullable().optional(),
expense_date: z.string().nullable().optional(),
@@ -148,6 +150,7 @@ export const budgetCreateItemRequestSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
// Link this expense to a reservation (e.g. created from a booking's
// "add expense" flow). The server stores it on budget_items.reservation_id.
@@ -168,6 +171,7 @@ export const budgetUpdateItemRequestSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'تراجع',
'costs.whatFor': 'لأجل ماذا كان؟',
'costs.namePlaceholder': 'مثل: عشاء، هدايا تذكارية، وقود…',
'costs.note': 'ملاحظة',
'costs.notePlaceholder': 'ما الذي شملته، ومن يدين بماذا…',
'costs.addNote': 'إضافة ملاحظة',
'costs.showNote': 'عرض الملاحظة',
'costs.hideNote': 'إخفاء الملاحظة',
'costs.totalAmount': 'المبلغ الإجمالي',
'costs.currency': 'العملة',
'costs.day': 'اليوم',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'حجز مؤكد',
'inspector.pendingRes': 'حجز قيد الانتظار',
'inspector.google': 'Google Maps',
'inspector.navigation': 'التنقل',
'inspector.openWith': 'الفتح باستخدام',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'فتح الموقع الإلكتروني',
'inspector.saveToCollection': 'حفظ في مجموعة',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'غير مخطط',
'places.planned': 'مخطط لها',
'places.filterTracks': 'المسارات',
'places.sortByRating': 'الترتيب حسب التقييم',
'places.filterByRating': 'تصفية حسب التقييم',
'places.yourRating': 'تقييمك',
'places.notRated': 'لم يُقيَّم بعد',
'places.search': 'ابحث عن أماكن...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Desfazer',
'costs.whatFor': 'Para que foi?',
'costs.namePlaceholder': 'ex.: jantar, lembranças, gasolina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'O que incluía, quem deve o quê…',
'costs.addNote': 'Adicionar nota',
'costs.showNote': 'Mostrar nota',
'costs.hideNote': 'Ocultar nota',
'costs.totalAmount': 'Valor total',
'costs.currency': 'Moeda',
'costs.day': 'Dia',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navegação',
'inspector.openWith': 'Abrir com',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Salvar na Coleção',
'inspector.savedToCollection': 'Salvo',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Não planejados',
'places.planned': 'Planejados',
'places.filterTracks': 'Trilhas',
'places.sortByRating': 'Ordenar por avaliação',
'places.filterByRating': 'Filtrar por avaliação',
'places.yourRating': 'Sua avaliação',
'places.notRated': 'Ainda sem avaliações',
'places.search': 'Buscar lugares...',
+5
View File
@@ -87,6 +87,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Desfer',
'costs.whatFor': 'Per a què ha estat?',
'costs.namePlaceholder': 'p. ex. Sopar, records, benzina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'Què incloïa, qui deu què…',
'costs.addNote': 'Afegeix una nota',
'costs.showNote': 'Mostra la nota',
'costs.hideNote': 'Amaga la nota',
'costs.totalAmount': 'Import total',
'costs.currency': 'Divisa',
'costs.day': 'Dia',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendent',
'inspector.google': 'Obre a Google Maps',
'inspector.navigation': 'Navegació',
'inspector.openWith': 'Obre amb',
'inspector.website': 'Obre el lloc web',
'inspector.addRes': 'Reserva',
'inspector.editRes': 'Edita la reserva',
+1 -1
View File
@@ -43,7 +43,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Sense planificar',
'places.planned': 'Planificats',
'places.filterTracks': 'Rutes',
'places.sortByRating': 'Ordena per valoració',
'places.filterByRating': 'Filtra per valoració',
'places.yourRating': 'La teva valoració',
'places.notRated': 'Encara sense valoració',
'places.search': 'Cerca llocs...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Vrátit zpět',
'costs.whatFor': 'Za co to bylo?',
'costs.namePlaceholder': 'např. večeře, suvenýry, benzín…',
'costs.note': 'Poznámka',
'costs.notePlaceholder': 'Co zahrnovalo, kdo co dluží…',
'costs.addNote': 'Přidat poznámku',
'costs.showNote': 'Zobrazit poznámku',
'costs.hideNote': 'Skrýt poznámku',
'costs.totalAmount': 'Celková částka',
'costs.currency': 'Měna',
'costs.day': 'Den',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Potvrzená rezervace',
'inspector.pendingRes': 'Čekající rezervace',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigace',
'inspector.openWith': 'Otevřít v',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Otevřít webové stránky',
'inspector.saveToCollection': 'Uložit do sbírky',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Nezařazené',
'places.planned': 'Naplánované',
'places.filterTracks': 'Trasy',
'places.sortByRating': 'Seřadit podle hodnocení',
'places.filterByRating': 'Filtrovat podle hodnocení',
'places.yourRating': 'Tvé hodnocení',
'places.notRated': 'Zatím bez hodnocení',
'places.search': 'Hledat místa...',
+5
View File
@@ -92,6 +92,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Rückgängig',
'costs.whatFor': 'Wofür war es?',
'costs.namePlaceholder': 'z.B. Abendessen, Souvenirs, Benzin…',
'costs.note': 'Notiz',
'costs.notePlaceholder': 'Was darin enthalten war, wer was zurückzahlt…',
'costs.addNote': 'Notiz hinzufügen',
'costs.showNote': 'Notiz anzeigen',
'costs.hideNote': 'Notiz ausblenden',
'costs.totalAmount': 'Gesamtbetrag',
'costs.currency': 'Währung',
'costs.day': 'Tag',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Bestätigte Reservierung',
'inspector.pendingRes': 'Ausstehende Reservierung',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Öffnen mit',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Webseite öffnen',
'inspector.saveToCollection': 'In Sammlung speichern',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Ungeplant',
'places.planned': 'Geplant',
'places.filterTracks': 'Tracks',
'places.sortByRating': 'Nach Bewertung sortieren',
'places.filterByRating': 'Nach Bewertung filtern',
'places.yourRating': 'Deine Bewertung',
'places.notRated': 'Noch nicht bewertet',
'places.search': 'Orte suchen...',
+5
View File
@@ -92,6 +92,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Undo',
'costs.whatFor': 'What was it for?',
'costs.namePlaceholder': 'e.g. Dinner, souvenirs, gas…',
'costs.note': 'Note',
'costs.notePlaceholder': 'What it covered, who owes what back…',
'costs.addNote': 'Add note',
'costs.showNote': 'Show note',
'costs.hideNote': 'Hide note',
'costs.totalAmount': 'Total amount',
'costs.currency': 'Currency',
'costs.day': 'Day',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Confirmed Reservation',
'inspector.pendingRes': 'Pending Reservation',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Open with',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Open Website',
'inspector.saveToCollection': 'Save to Collection',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Unplanned',
'places.planned': 'Planned',
'places.filterTracks': 'Tracks',
'places.sortByRating': 'Sort by rating',
'places.filterByRating': 'Filter by rating',
'places.yourRating': 'Your rating',
'places.notRated': 'Not rated yet',
'places.search': 'Search places...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Deshacer',
'costs.whatFor': '¿Para qué fue?',
'costs.namePlaceholder': 'p. ej. Cena, souvenirs, gasolina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'Qué incluía, quién debe qué…',
'costs.addNote': 'Añadir nota',
'costs.showNote': 'Mostrar nota',
'costs.hideNote': 'Ocultar nota',
'costs.totalAmount': 'Importe total',
'costs.currency': 'Moneda',
'costs.day': 'Día',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendiente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navegación',
'inspector.openWith': 'Abrir con',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Abrir la web',
'inspector.saveToCollection': 'Guardar en colección',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Sin planificar',
'places.planned': 'Planificados',
'places.filterTracks': 'Rutas',
'places.sortByRating': 'Ordenar por valoración',
'places.filterByRating': 'Filtrar por valoración',
'places.yourRating': 'Tu valoración',
'places.notRated': 'Sin valorar todavía',
'places.search': 'Buscar lugares...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Annuler',
'costs.whatFor': "C'était pour quoi ?",
'costs.namePlaceholder': 'ex. dîner, souvenirs, essence…',
'costs.note': 'Note',
'costs.notePlaceholder': 'Ce que ça couvrait, qui doit quoi…',
'costs.addNote': 'Ajouter une note',
'costs.showNote': 'Afficher la note',
'costs.hideNote': 'Masquer la note',
'costs.totalAmount': 'Montant total',
'costs.currency': 'Devise',
'costs.day': 'Jour',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Réservation confirmée',
'inspector.pendingRes': 'Réservation en attente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Ouvrir avec',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Ouvrir le site web',
'inspector.saveToCollection': 'Enregistrer dans une collection',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Non planifiés',
'places.planned': 'Planifiés',
'places.filterTracks': 'Traces',
'places.sortByRating': 'Trier par note',
'places.filterByRating': 'Filtrer par note',
'places.yourRating': 'Ta note',
'places.notRated': 'Pas encore noté',
'places.search': 'Rechercher des lieux…',
+5
View File
@@ -90,6 +90,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Αναίρεση',
'costs.whatFor': 'Για τι ήταν;',
'costs.namePlaceholder': 'π.χ. Δείπνο, σουβενίρ, βενζίνη…',
'costs.note': 'Σημείωση',
'costs.notePlaceholder': 'Τι κάλυπτε, ποιος χρωστάει τι…',
'costs.addNote': 'Προσθήκη σημείωσης',
'costs.showNote': 'Εμφάνιση σημείωσης',
'costs.hideNote': 'Απόκρυψη σημείωσης',
'costs.totalAmount': 'Συνολικό ποσό',
'costs.currency': 'Νόμισμα',
'costs.day': 'Ημέρα',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Επιβεβαιωμένη Κράτηση',
'inspector.pendingRes': 'Εκκρεμής Κράτηση',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Πλοήγηση',
'inspector.openWith': 'Άνοιγμα με',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Αποθήκευση στη Συλλογή',
'inspector.savedToCollection': 'Αποθηκεύτηκε',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Μη προγραμματισμένα',
'places.planned': 'Προγραμματισμένα',
'places.filterTracks': 'Ίχνη',
'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία',
'places.filterByRating': 'Φιλτράρισμα κατά βαθμολογία',
'places.yourRating': 'Η βαθμολογία σου',
'places.notRated': 'Χωρίς βαθμολογία ακόμη',
'places.search': 'Αναζήτηση μερών...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Visszavonás',
'costs.whatFor': 'Mire volt?',
'costs.namePlaceholder': 'pl. vacsora, ajándékok, benzin…',
'costs.note': 'Megjegyzés',
'costs.notePlaceholder': 'Mit fedezett, ki tartozik mivel…',
'costs.addNote': 'Megjegyzés hozzáadása',
'costs.showNote': 'Megjegyzés megjelenítése',
'costs.hideNote': 'Megjegyzés elrejtése',
'costs.totalAmount': 'Teljes összeg',
'costs.currency': 'Pénznem',
'costs.day': 'Nap',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Megerősített foglalás',
'inspector.pendingRes': 'Függőben lévő foglalás',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigáció',
'inspector.openWith': 'Megnyitás ezzel',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Weboldal megnyitása',
'inspector.saveToCollection': 'Mentés gyűjteménybe',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Nem tervezett',
'places.planned': 'Tervezett',
'places.filterTracks': 'Nyomvonalak',
'places.sortByRating': 'Rendezés értékelés szerint',
'places.filterByRating': 'Szűrés értékelés szerint',
'places.yourRating': 'Az értékelésed',
'places.notRated': 'Még nincs értékelve',
'places.search': 'Helyek keresése...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Urungkan',
'costs.whatFor': 'Untuk apa?',
'costs.namePlaceholder': 'mis. Makan malam, oleh-oleh, bensin…',
'costs.note': 'Catatan',
'costs.notePlaceholder': 'Apa saja yang termasuk, siapa berutang apa…',
'costs.addNote': 'Tambah catatan',
'costs.showNote': 'Tampilkan catatan',
'costs.hideNote': 'Sembunyikan catatan',
'costs.totalAmount': 'Jumlah total',
'costs.currency': 'Mata uang',
'costs.day': 'Hari',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reservasi Dikonfirmasi',
'inspector.pendingRes': 'Reservasi Menunggu',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigasi',
'inspector.openWith': 'Buka dengan',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Simpan ke Koleksi',
'inspector.savedToCollection': 'Tersimpan',
+1 -1
View File
@@ -44,7 +44,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Belum direncanakan',
'places.planned': 'Direncanakan',
'places.filterTracks': 'Trek',
'places.sortByRating': 'Urutkan berdasarkan rating',
'places.filterByRating': 'Saring menurut penilaian',
'places.yourRating': 'Rating kamu',
'places.notRated': 'Belum dinilai',
'places.search': 'Cari tempat...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Annulla',
'costs.whatFor': 'Per cosa era?',
'costs.namePlaceholder': 'es. Cena, souvenir, benzina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'Cosa comprendeva, chi deve cosa…',
'costs.addNote': 'Aggiungi nota',
'costs.showNote': 'Mostra nota',
'costs.hideNote': 'Nascondi nota',
'costs.totalAmount': 'Importo totale',
'costs.currency': 'Valuta',
'costs.day': 'Giorno',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Prenotazione confermata',
'inspector.pendingRes': 'Prenotazione in attesa',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigazione',
'inspector.openWith': 'Apri con',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Apri sito web',
'inspector.saveToCollection': 'Salva nella raccolta',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Non pianificati',
'places.planned': 'Pianificati',
'places.filterTracks': 'Tracce',
'places.sortByRating': 'Ordina per valutazione',
'places.filterByRating': 'Filtra per valutazione',
'places.yourRating': 'La tua valutazione',
'places.notRated': 'Non ancora valutato',
'places.search': 'Cerca luoghi...',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': '元に戻す',
'costs.whatFor': '何の支出ですか?',
'costs.namePlaceholder': '例:夕食、お土産、ガソリン…',
'costs.note': 'メモ',
'costs.notePlaceholder': '内訳や立て替えのメモなど…',
'costs.addNote': 'メモを追加',
'costs.showNote': 'メモを表示',
'costs.hideNote': 'メモを非表示',
'costs.totalAmount': '合計金額',
'costs.currency': '通貨',
'costs.day': '日',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': '確定済み予約',
'inspector.pendingRes': '保留中の予約',
'inspector.google': 'Google Maps',
'inspector.navigation': 'ナビゲーション',
'inspector.openWith': 'アプリで開く',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Webサイトを開く',
'inspector.saveToCollection': 'コレクションに保存',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': '未計画',
'places.planned': '計画済み',
'places.filterTracks': 'トラック',
'places.sortByRating': '評価順に並べ替え',
'places.filterByRating': '評価で絞り込む',
'places.yourRating': 'あなたの評価',
'places.notRated': '未評価',
'places.search': '場所を検索…',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': '실행 취소',
'costs.whatFor': '무엇을 위한 것인가요?',
'costs.namePlaceholder': '예: 저녁 식사, 기념품, 주유…',
'costs.note': '메모',
'costs.notePlaceholder': '무엇을 샀는지, 누가 갚아야 하는지…',
'costs.addNote': '메모 추가',
'costs.showNote': '메모 보기',
'costs.hideNote': '메모 숨기기',
'costs.totalAmount': '총 금액',
'costs.currency': '통화',
'costs.day': '날짜',

Some files were not shown because too many files have changed in this diff Show More