feat(dashboard): search every trip by title, date or a place on it (#2190)

This commit is contained in:
Maurice
2026-10-01 13:30:44 +02:00
parent 99c62878f3
commit 8c37597bd7
38 changed files with 372 additions and 10 deletions
+2 -1
View File
@@ -26,7 +26,7 @@ import {
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse, type TripSearchResponse,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
type PlaceCreateRequest, type PlaceUpdateRequest, type PlaceImageFromFileRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
@@ -427,6 +427,7 @@ export const tripsApi = {
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData),
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
search: (q: string): Promise<TripSearchResponse> => apiClient.get('/trips/search', { params: { q } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
+44
View File
@@ -1056,4 +1056,48 @@ describe('DashboardPage', () => {
expect(card.querySelector('[aria-label="Archive"]')).toBeNull();
});
});
describe('FE-PAGE-DASH-2190: trip search', () => {
it('finds trips across filters by title and date, and by a place on them, then clears', async () => {
const oldTrip = buildTrip({ id: 77, title: 'Old Rome Trip', start_date: '2024-05-01', end_date: '2024-05-07', is_archived: 1 });
const philly = buildTrip({ id: 78, title: 'East Coast', start_date: '2026-09-01', end_date: '2026-09-07' });
server.use(
http.get('/api/trips', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [oldTrip] });
return HttpResponse.json({ trips: [buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' }), philly] });
}),
http.get('/api/trips/search', ({ request }) => {
const q = new URL(request.url).searchParams.get('q');
return HttpResponse.json({ matches: q === 'diner' ? [{ trip_id: 78, places: ["Dante's Diner"] }] : [] });
}),
);
const user = userEvent.setup();
render(<DashboardPage />);
await waitFor(() => expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument());
const box = screen.getByRole('searchbox', { name: 'Search all trips by title, date or place' });
await user.type(box, 'rome');
// The archived trip shows up without switching the filter.
expect(await screen.findByText('Old Rome Trip')).toBeInTheDocument();
await user.clear(box);
await user.type(box, 'may 2024');
expect(await screen.findByText('Old Rome Trip')).toBeInTheDocument();
await user.clear(box);
await user.type(box, 'diner');
expect(await screen.findByText("Dante's Diner")).toBeInTheDocument();
expect(screen.getByText('East Coast')).toBeInTheDocument();
await user.clear(box);
await user.type(box, 'zzzz');
expect(await screen.findByText('No trip matches “zzzz”')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Clear search' }));
expect(box).toHaveValue('');
expect(screen.queryByText('Old Rome Trip')).not.toBeInTheDocument();
});
});
});
+38 -6
View File
@@ -19,7 +19,7 @@ import {
import {
Plus, Edit2, Trash2, Archive, ArchiveRestore, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare, LogIn, LogOut,
LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare, LogIn, LogOut, Search,
} from 'lucide-react'
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
import CollectionsWidget from '../components/Dashboard/CollectionsWidget'
@@ -120,7 +120,7 @@ function DashboardPageDesktop(): React.ReactElement {
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, applyCoverUpdate,
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
allSubOpen, setAllSubOpen,
allSubOpen, setAllSubOpen, search,
} = useDashboard()
// Dashboard widget visibility (from the appearance config). Phones never reach this
@@ -191,7 +191,24 @@ function DashboardPageDesktop(): React.ReactElement {
<div className="sec-head">
<h3 className="sec-title">{t('dashboard.title')}</h3>
<div className="sec-tools">
<div className="seg">
{/* Trip search (#2190): every trip, by title, date or a place on it */}
<label className={`dash-search${search.active ? ' on' : ''}`}>
<Search size={15} strokeWidth={2.2} aria-hidden />
<input
type="search"
value={search.query}
onChange={e => search.setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') search.setQuery('') }}
placeholder={t('dashboard.search.placeholder')}
aria-label={t('dashboard.search.label')}
/>
{search.active && (
<button type="button" className="dash-search-clear" onClick={() => search.setQuery('')} aria-label={t('dashboard.search.clear')}>
<X size={13} strokeWidth={2.4} />
</button>
)}
</label>
<div className={`seg${search.active ? ' is-muted' : ''}`}>
<button type="button" className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
<button type="button" className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
<button type="button" className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
@@ -222,7 +239,13 @@ function DashboardPageDesktop(): React.ReactElement {
{/* "No trips yet" only when there really are none — a user whose trips are
all finished has a hero, and telling them to create their first trip is
simply wrong (#1706). Same condition the mobile dashboard already uses. */}
{gridTrips.length === 0 && !spotlight && tripFilter === 'planned' && !isLoading && !loadError && (
{search.active && gridTrips.length === 0 && (
<div className="trips-empty">
<EmptyState scene="dashboard" title={t('dashboard.search.empty', { query: search.query.trim() })} />
</div>
)}
{!search.active && gridTrips.length === 0 && !spotlight && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<EmptyState scene="dashboard" title={t('dashboard.emptyTitle')} />
</div>
@@ -235,6 +258,7 @@ function DashboardPageDesktop(): React.ReactElement {
trip={trip}
locale={locale}
badges={badgesFor(trip.id)}
matchedPlaces={search.active ? search.placeHits.get(trip.id) : undefined}
onOpen={() => navigate(`/trips/${trip.id}`)}
onEdit={() => { setEditingTrip(trip); setShowForm(true) }}
onCopy={() => setCopyTrip(trip)}
@@ -249,7 +273,7 @@ function DashboardPageDesktop(): React.ReactElement {
<TripCardSkeleton />
</>
)}
{tripFilter === 'planned' && !isLoading && (
{tripFilter === 'planned' && !search.active && !isLoading && (
<button type="button" className="add-trip-card" onClick={() => { setEditingTrip(null); setShowForm(true) }}>
<div>
<div className="circ"><Plus size={20} /></div>
@@ -575,8 +599,10 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
}
// ── Trip card ────────────────────────────────────────────────────────────────
function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onDelete }: {
function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy, onArchive, onDelete }: {
trip: DashboardTrip; locale: string; badges?: TripCardBadge[]; onOpen: () => void
/** The places that made this trip a search result (#2190). */
matchedPlaces?: string[]
onEdit: () => void; onCopy: () => void; onArchive: () => void; onDelete: () => void
}): React.ReactElement {
const { t } = useTranslation()
@@ -631,6 +657,12 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD
</>
) : <span>{t('dashboard.hero.noDates')}</span>}
</div>
{matchedPlaces && matchedPlaces.length > 0 && (
<div className="trip-match" title={matchedPlaces.join(', ')}>
<MapPin size={12} strokeWidth={2.2} aria-hidden />
<span>{matchedPlaces.join(' · ')}</span>
</div>
)}
<div className="trip-meta" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
<div><span className="n mono">{trip.day_count ?? 0}</span><span className="k">{t('dashboard.days')}</span></div>
<div><span className="n mono">{trip.place_count ?? 0}</span><span className="k">{t('dashboard.places')}</span></div>
+6 -1
View File
@@ -7,6 +7,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import { localIsoToday } from './dashboardModel'
import { useTripSearch } from './useTripSearch'
import type { TripCreateRequest } from '@trek/shared'
import {
type DashboardTrip,
@@ -193,7 +194,10 @@ export function useDashboard() {
setArchivedTrips(patch)
}
const gridTrips = tripFilter === 'archive' ? archivedTrips
// While a search runs it spans every trip, archived ones included (#2190).
const search = useTripSearch(trips, archivedTrips, locale)
const gridTrips = search.active ? search.results
: tripFilter === 'archive' ? archivedTrips
: tripFilter === 'completed' ? rest.filter(t => getTripStatus(t) === 'past')
: rest.filter(t => getTripStatus(t) !== 'past')
@@ -208,6 +212,7 @@ export function useDashboard() {
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, applyCoverUpdate,
allSubOpen, setAllSubOpen,
search,
// actions
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
}
@@ -0,0 +1,63 @@
import { useEffect, useMemo, useState } from 'react'
import { tripsApi } from '../../api/client'
import { isEffectivelyOffline } from '../../sync/networkMode'
import type { DashboardTrip } from './dashboardModel'
/** Lower-cased, accents folded, so "cafe" finds "Café". */
const fold = (s: string) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
/**
* Whether a trip's own words match (#2190): its title, its description, and its
* dates both as written (2024-05) and as read (May 2024, in the viewer's language).
*/
export function tripMatchesText(trip: DashboardTrip, query: string, locale?: string): boolean {
const q = fold(query.trim())
if (!q) return true
const dates = [trip.start_date, trip.end_date].filter((d): d is string => !!d)
const spoken = dates.map(d => {
const parsed = new Date(`${d}T00:00:00`)
return Number.isNaN(parsed.getTime()) ? '' : parsed.toLocaleDateString(locale, { month: 'long', year: 'numeric' })
})
const haystack = [trip.title, trip.description ?? '', ...dates, ...spoken].map(fold).join(' \n ')
return q.split(/\s+/).every(word => haystack.includes(word))
}
const DEBOUNCE_MS = 250
/**
* The dashboard's trip search (#2190). Searches every trip, archived ones too,
* by its own words on the device and by its places on the server; offline the
* place half simply stays out. Returns the matching trips and, per trip, the
* places that made it match.
*/
export function useTripSearch(trips: DashboardTrip[], archivedTrips: DashboardTrip[], locale?: string) {
const [query, setQuery] = useState('')
const [placeHits, setPlaceHits] = useState<Map<number, string[]>>(new Map())
const active = query.trim().length > 0
useEffect(() => {
const q = query.trim()
if (q.length < 2 || isEffectivelyOffline()) { setPlaceHits(new Map()); return }
let cancelled = false
const timer = setTimeout(() => {
tripsApi.search(q)
.then(({ matches }) => { if (!cancelled) setPlaceHits(new Map(matches.map(m => [m.trip_id, m.places]))) })
// The place half is a bonus on top of the local match; a failed request
// leaves the title and date results standing.
.catch(() => { if (!cancelled) setPlaceHits(new Map()) })
}, DEBOUNCE_MS)
return () => { cancelled = true; clearTimeout(timer) }
}, [query])
const results = useMemo(() => {
if (!active) return []
const seen = new Set<number>()
return [...trips, ...archivedTrips].filter(trip => {
if (seen.has(trip.id)) return false
seen.add(trip.id)
return tripMatchesText(trip, query, locale) || placeHits.has(trip.id)
})
}, [active, trips, archivedTrips, query, locale, placeHits])
return { query, setQuery, active, results, placeHits }
}
+18
View File
@@ -429,6 +429,24 @@
.dark .trek-dash .seg { background: var(--bg-2); }
.trek-dash .seg button { padding: 7px 14px; font-size: 13px; border-radius: 8px; color: var(--ink-2); font-weight: 500; transition: background .12s, color .12s; }
.trek-dash .seg button.on { background: var(--surface); color: var(--ink); box-shadow: var(--sh-xs); }
/* While a search spans every trip the filter does not apply: quieted, still usable. */
.trek-dash .seg.is-muted { opacity: .5; }
.trek-dash .seg.is-muted button.on { box-shadow: none; }
/* trip search (#2190): the segment's height and ground, widening on focus */
.trek-dash .dash-search { display: flex; align-items: center; gap: 7px; height: 38px; width: 190px; padding: 0 8px 0 11px; border-radius: 11px; background: oklch(0.94 0.008 70); color: var(--ink-3); transition: width .2s ease, background .12s, box-shadow .12s; }
.dark .trek-dash .dash-search { background: var(--bg-2); }
.trek-dash .dash-search:focus-within,
.trek-dash .dash-search.on { width: 260px; background: var(--surface); box-shadow: var(--sh-xs), 0 0 0 1px var(--line); color: var(--ink-2); }
.trek-dash .dash-search input { flex: 1; min-width: 0; border: 0; background: transparent; outline: none; font: inherit; font-size: 13px; color: var(--ink); }
.trek-dash .dash-search input::placeholder { color: var(--ink-3); }
.trek-dash .dash-search input::-webkit-search-cancel-button { display: none; }
.trek-dash .dash-search-clear { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; color: var(--ink-3); flex: none; }
.trek-dash .dash-search-clear:hover { background: var(--bg-2); color: var(--ink); }
.trek-dash .trip-match { display: flex; align-items: center; justify-content: center; gap: 5px; margin: -6px 0 12px; font-size: 12px; color: var(--ink-2); min-width: 0; }
.trek-dash .trip-match span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .trip-match svg { flex: none; color: var(--ink-3); }
.trek-dash .trips.list-view .trip-match { margin: 0; justify-content: flex-start; max-width: 220px; }
/* ----------------- trips grid ----------------- */
.trek-dash .trips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; margin-bottom: 56px; transition: all 0.3s ease; }
+7 -1
View File
@@ -23,7 +23,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
import type { Request, Response } from 'express';
import type { Options } from 'multer';
import path from 'path';
import { MAX_TRIP_DAYS, type ActiveTripResponse } from '@trek/shared';
import { MAX_TRIP_DAYS, type ActiveTripResponse, type TripSearchResponse } from '@trek/shared';
import { StorageService } from '../storage/storage.service';
import type { User } from '../../types';
import { TripsService } from './trips.service';
@@ -86,6 +86,12 @@ export class TripsController {
return { trip: { id, title, start_date, end_date } };
}
/** Places across the user's trips for the dashboard search (#2190). Above @Get(':id'). */
@Get('search')
search(@CurrentUser() user: User, @Query('q') q?: string): TripSearchResponse {
return { matches: this.trips.searchPlaces(user.id, typeof q === 'string' ? q.slice(0, 100) : '') };
}
@Get('cover-images/search')
async coverImages(@CurrentUser() user: User, @Query('query') query?: string) {
try {
+28
View File
@@ -306,6 +306,34 @@ export class TripsService {
`).get({ userId, today }) as ActiveTrip & { relevance: number } | undefined;
}
/**
* The user's trips, archived ones included, with places whose name or address
* contains `query` (#2190). Up to three names per trip; a query under two
* characters matches nothing, it would match every place there is.
*/
searchPlaces(userId: number, query: string): { trip_id: number; places: string[] }[] {
const q = query.trim();
if (q.length < 2) return [];
const like = `%${q.replace(/[\\%_]/g, ch => `\\${ch}`)}%`;
const rows = this.db.prepare(`
SELECT p.trip_id, p.name
FROM places p
JOIN trips t ON t.id = p.trip_id
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
AND (p.name LIKE :like ESCAPE '\\' OR p.address LIKE :like ESCAPE '\\')
ORDER BY p.trip_id, p.name
LIMIT 500
`).all({ userId, like }) as { trip_id: number; name: string }[];
const byTrip = new Map<number, string[]>();
for (const row of rows) {
const names = byTrip.get(row.trip_id) ?? [];
if (names.length < 3 && !names.includes(row.name)) names.push(row.name);
byTrip.set(row.trip_id, names);
}
return [...byTrip].map(([trip_id, places]) => ({ trip_id, places }));
}
getRaw(tripId: string | number): Trip | undefined {
return this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
}
@@ -23,7 +23,7 @@ import type { CalendarService } from '../../../src/nest/calendar/calendar.servic
import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
import type { User } from '../../../src/types';
import { MAX_TRIP_DAYS, activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
import { MAX_TRIP_DAYS, activeTripResponseSchema, tripSearchResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const req = { headers: {} } as Request;
@@ -112,6 +112,20 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
});
});
describe('GET /search (#2190)', () => {
it('hands the query to the service, capped, and answers the contract shape', () => {
const searchPlaces = vi.fn().mockReturnValue([{ trip_id: 4, places: ['Diner'] }]);
const ctl = tc(svc({ searchPlaces } as Partial<TripsService>));
const res = ctl.search(user, 'diner');
expect(res).toEqual({ matches: [{ trip_id: 4, places: ['Diner'] }] });
expect(tripSearchResponseSchema.safeParse(res).success).toBe(true);
ctl.search(user, 'x'.repeat(300));
expect(searchPlaces).toHaveBeenLastCalledWith(1, 'x'.repeat(100));
ctl.search(user, undefined);
expect(searchPlaces).toHaveBeenLastCalledWith(1, '');
});
});
describe('GET /cover-images/search', () => {
it('passes the caller through to the service and unwraps the photo list', async () => {
const searchCoverImages = vi.fn().mockResolvedValue({ photos: [{ id: 'p1' }] });
@@ -1519,6 +1519,36 @@ describe('quirk fixes', () => {
* running today → next one starting (earliest first) → most recently started →
* undated last. If these drift, "open my trip" and the hero show different trips.
*/
describe('searchPlaces (#2190)', () => {
it('TRIP-SVC-2190-1: finds places by name or address across own and shared trips, archived ones too', () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const own = createTrip(testDb, user.id, { title: 'Philly' });
const shared = createTrip(testDb, other.id, { title: 'SF' });
addTripMember(testDb, shared.id, user.id);
const foreign = createTrip(testDb, other.id, { title: 'Hidden' });
createPlace(testDb, own.id, { name: "Dante's Diner" });
const museum = createPlace(testDb, own.id, { name: 'Museum' });
testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('1 Diner Street', museum.id);
createPlace(testDb, shared.id, { name: 'City Lights Books' });
createPlace(testDb, foreign.id, { name: 'Secret Diner' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(own.id);
expect(svc.searchPlaces(user.id, 'diner')).toEqual([{ trip_id: own.id, places: ["Dante's Diner", 'Museum'] }]);
expect(svc.searchPlaces(user.id, 'books')).toEqual([{ trip_id: shared.id, places: ['City Lights Books'] }]);
});
it('TRIP-SVC-2190-2: wildcards are literal, short queries match nothing, and three names per trip at most', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
for (const name of ['Cafe A', 'Cafe B', 'Cafe C', 'Cafe D', '100% Juice']) createPlace(testDb, trip.id, { name });
expect(svc.searchPlaces(user.id, 'cafe')[0].places).toHaveLength(3);
expect(svc.searchPlaces(user.id, '%')).toEqual([]);
expect(svc.searchPlaces(user.id, '0%')[0].places).toEqual(['100% Juice']);
expect(svc.searchPlaces(user.id, '_a')).toEqual([]);
});
});
describe('activeTrip (startup destination)', () => {
const TODAY = '2026-08-08';
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'نسخة',
'dashboard.restore': 'استعادة',
'dashboard.archived': 'مؤرشفة',
'dashboard.search.placeholder': 'ابحث عن رحلات أو أماكن',
'dashboard.search.label': 'ابحث في كل الرحلات بالعنوان أو التاريخ أو المكان',
'dashboard.search.clear': 'مسح البحث',
'dashboard.search.empty': 'لا توجد رحلة تطابق «{query}»',
'dashboard.status.ongoing': 'جارية',
'dashboard.status.today': 'اليوم',
'dashboard.status.tomorrow': 'غدًا',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'nüsxə',
'dashboard.restore': 'Bərpa et',
'dashboard.archived': 'Arxivləşdirilib',
'dashboard.search.placeholder': 'Səfər və ya yer axtar',
'dashboard.search.label': 'Bütün səfərləri ad, tarix və ya yerə görə axtar',
'dashboard.search.clear': 'Axtarışı təmizlə',
'dashboard.search.empty': '“{query}” üzrə səfər tapılmadı',
'dashboard.status.ongoing': 'Davam edir',
'dashboard.status.today': 'Bu gün',
'dashboard.status.tomorrow': 'Sabah',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'cópia',
'dashboard.restore': 'Restaurar',
'dashboard.archived': 'Arquivada',
'dashboard.search.placeholder': 'Buscar viagens ou lugares',
'dashboard.search.label': 'Buscar em todas as viagens por título, data ou lugar',
'dashboard.search.clear': 'Limpar busca',
'dashboard.search.empty': 'Nenhuma viagem corresponde a “{query}”',
'dashboard.status.ongoing': 'Em andamento',
'dashboard.status.today': 'Hoje',
'dashboard.status.tomorrow': 'Amanhã',
+4
View File
@@ -36,6 +36,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'còpia',
'dashboard.restore': 'Restaura',
'dashboard.archived': 'Arxivat',
'dashboard.search.placeholder': 'Cerca viatges o llocs',
'dashboard.search.label': 'Cerca tots els viatges per títol, data o lloc',
'dashboard.search.clear': 'Esborra la cerca',
'dashboard.search.empty': 'Cap viatge coincideix amb «{query}»',
'dashboard.status.ongoing': 'En curs',
'dashboard.status.today': 'Avui',
'dashboard.status.tomorrow': 'Demà',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'kopie',
'dashboard.restore': 'Obnovit',
'dashboard.archived': 'Archivováno',
'dashboard.search.placeholder': 'Hledat výlety nebo místa',
'dashboard.search.label': 'Prohledat všechny výlety podle názvu, data nebo místa',
'dashboard.search.clear': 'Vymazat hledání',
'dashboard.search.empty': 'Žádný výlet neodpovídá „{query}“',
'dashboard.status.ongoing': 'Probíhající',
'dashboard.status.today': 'Dnes',
'dashboard.status.tomorrow': 'Zítra',
+4
View File
@@ -40,6 +40,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'Kopie',
'dashboard.restore': 'Wiederherstellen',
'dashboard.archived': 'Archiviert',
'dashboard.search.placeholder': 'Trips oder Orte suchen',
'dashboard.search.label': 'Alle Trips nach Titel, Datum oder Ort durchsuchen',
'dashboard.search.clear': 'Suche leeren',
'dashboard.search.empty': 'Kein Trip passt zu „{query}“',
'dashboard.status.ongoing': 'Laufend',
'dashboard.status.today': 'Heute',
'dashboard.status.tomorrow': 'Morgen',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'copy',
'dashboard.restore': 'Restore',
'dashboard.archived': 'Archived',
'dashboard.search.placeholder': 'Search trips or places',
'dashboard.search.label': 'Search all trips by title, date or place',
'dashboard.search.clear': 'Clear search',
'dashboard.search.empty': 'No trip matches “{query}”',
'dashboard.status.ongoing': 'Ongoing',
'dashboard.status.today': 'Today',
'dashboard.status.tomorrow': 'Tomorrow',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'copia',
'dashboard.restore': 'Restaurar',
'dashboard.archived': 'Archivado',
'dashboard.search.placeholder': 'Buscar viajes o lugares',
'dashboard.search.label': 'Buscar en todos los viajes por título, fecha o lugar',
'dashboard.search.clear': 'Borrar búsqueda',
'dashboard.search.empty': 'Ningún viaje coincide con «{query}»',
'dashboard.status.ongoing': 'En curso',
'dashboard.status.today': 'Hoy',
'dashboard.status.tomorrow': 'Mañana',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'koopia',
'dashboard.restore': 'Taasta',
'dashboard.archived': 'Arhiveeritud',
'dashboard.search.placeholder': 'Otsi reise või kohti',
'dashboard.search.label': 'Otsi kõiki reise pealkirja, kuupäeva või koha järgi',
'dashboard.search.clear': 'Tühjenda otsing',
'dashboard.search.empty': 'Ükski reis ei vasta otsingule „{query}“',
'dashboard.status.ongoing': 'Käimas',
'dashboard.status.today': 'Täna',
'dashboard.status.tomorrow': 'Homme',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'copie',
'dashboard.restore': 'Restaurer',
'dashboard.archived': 'Archivé',
'dashboard.search.placeholder': 'Rechercher voyages ou lieux',
'dashboard.search.label': 'Rechercher tous les voyages par titre, date ou lieu',
'dashboard.search.clear': 'Effacer la recherche',
'dashboard.search.empty': 'Aucun voyage ne correspond à « {query} »',
'dashboard.status.ongoing': 'En cours',
'dashboard.status.today': "Aujourd'hui",
'dashboard.status.tomorrow': 'Demain',
+4
View File
@@ -35,6 +35,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'αντιγραφή',
'dashboard.restore': 'Επαναφορά',
'dashboard.archived': 'Αρχειοθετημένο',
'dashboard.search.placeholder': 'Αναζήτηση ταξιδιών ή τοποθεσιών',
'dashboard.search.label': 'Αναζήτηση όλων των ταξιδιών με τίτλο, ημερομηνία ή τοποθεσία',
'dashboard.search.clear': 'Καθαρισμός αναζήτησης',
'dashboard.search.empty': 'Κανένα ταξίδι δεν ταιριάζει με «{query}»',
'dashboard.status.ongoing': 'Τρέχων',
'dashboard.status.today': 'Σήμερα',
'dashboard.status.tomorrow': 'Αύριο',
+4
View File
@@ -40,6 +40,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'másolat',
'dashboard.restore': 'Visszaállítás',
'dashboard.archived': 'Archivált',
'dashboard.search.placeholder': 'Utak vagy helyek keresése',
'dashboard.search.label': 'Összes út keresése cím, dátum vagy hely szerint',
'dashboard.search.clear': 'Keresés törlése',
'dashboard.search.empty': 'Egy út sem felel meg ennek: „{query}”',
'dashboard.status.ongoing': 'Folyamatban',
'dashboard.status.today': 'Ma',
'dashboard.status.tomorrow': 'Holnap',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'salinan',
'dashboard.restore': 'Pulihkan',
'dashboard.archived': 'Diarsipkan',
'dashboard.search.placeholder': 'Cari perjalanan atau tempat',
'dashboard.search.label': 'Cari semua perjalanan menurut judul, tanggal, atau tempat',
'dashboard.search.clear': 'Hapus pencarian',
'dashboard.search.empty': 'Tidak ada perjalanan yang cocok dengan “{query}”',
'dashboard.status.ongoing': 'Sedang berlangsung',
'dashboard.status.today': 'Hari ini',
'dashboard.status.tomorrow': 'Besok',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'copia',
'dashboard.restore': 'Ripristina',
'dashboard.archived': 'Archiviati',
'dashboard.search.placeholder': 'Cerca viaggi o luoghi',
'dashboard.search.label': 'Cerca tutti i viaggi per titolo, data o luogo',
'dashboard.search.clear': 'Cancella ricerca',
'dashboard.search.empty': 'Nessun viaggio corrisponde a «{query}»',
'dashboard.status.ongoing': 'In corso',
'dashboard.status.today': 'Oggi',
'dashboard.status.tomorrow': 'Domani',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'コピー',
'dashboard.restore': '復元',
'dashboard.archived': 'アーカイブ済み',
'dashboard.search.placeholder': '旅行や場所を検索',
'dashboard.search.label': 'タイトル・日付・場所ですべての旅行を検索',
'dashboard.search.clear': '検索をクリア',
'dashboard.search.empty': '「{query}」に一致する旅行はありません',
'dashboard.status.ongoing': '進行中',
'dashboard.status.today': '今日',
'dashboard.status.tomorrow': '明日',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': '사본',
'dashboard.restore': '복원',
'dashboard.archived': '보관됨',
'dashboard.search.placeholder': '여행 또는 장소 검색',
'dashboard.search.label': '제목, 날짜, 장소로 모든 여행 검색',
'dashboard.search.clear': '검색 지우기',
'dashboard.search.empty': '“{query}”와(과) 일치하는 여행이 없습니다',
'dashboard.status.ongoing': '진행 중',
'dashboard.status.today': '오늘',
'dashboard.status.tomorrow': '내일',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'kopie',
'dashboard.restore': 'Herstellen',
'dashboard.archived': 'Gearchiveerd',
'dashboard.search.placeholder': 'Zoek trips of plekken',
'dashboard.search.label': 'Doorzoek alle trips op titel, datum of plek',
'dashboard.search.clear': 'Zoekopdracht wissen',
'dashboard.search.empty': 'Geen trip komt overeen met ‘{query}’',
'dashboard.status.ongoing': 'Lopend',
'dashboard.status.today': 'Vandaag',
'dashboard.status.tomorrow': 'Morgen',
+4
View File
@@ -36,6 +36,10 @@ const dashboard: TranslationStrings = {
'dashboard.archive': 'Archiwizuj',
'dashboard.restore': 'Przywróć',
'dashboard.archived': 'Zarchiwizowana',
'dashboard.search.placeholder': 'Szukaj podróży lub miejsc',
'dashboard.search.label': 'Przeszukaj wszystkie podróże po tytule, dacie lub miejscu',
'dashboard.search.clear': 'Wyczyść wyszukiwanie',
'dashboard.search.empty': 'Żadna podróż nie pasuje do „{query}”',
'dashboard.status.ongoing': 'W trakcie',
'dashboard.status.today': 'Dzisiaj',
'dashboard.status.tomorrow': 'Jutro',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'копия',
'dashboard.restore': 'Восстановить',
'dashboard.archived': 'В архиве',
'dashboard.search.placeholder': 'Поиск поездок или мест',
'dashboard.search.label': 'Искать все поездки по названию, дате или месту',
'dashboard.search.clear': 'Очистить поиск',
'dashboard.search.empty': 'Нет поездок по запросу «{query}»',
'dashboard.status.ongoing': 'В процессе',
'dashboard.status.today': 'Сегодня',
'dashboard.status.tomorrow': 'Завтра',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'kópia',
'dashboard.restore': 'Obnoviť',
'dashboard.archived': 'Archivované',
'dashboard.search.placeholder': 'Hľadať výlety alebo miesta',
'dashboard.search.label': 'Prehľadať všetky výlety podľa názvu, dátumu alebo miesta',
'dashboard.search.clear': 'Vymazať hľadanie',
'dashboard.search.empty': 'Žiadny výlet nezodpovedá „{query}“',
'dashboard.status.ongoing': 'Prebiehajúca',
'dashboard.status.today': 'Dnes',
'dashboard.status.tomorrow': 'Zajtra',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'kopiera',
'dashboard.restore': 'Återställ',
'dashboard.archived': 'Arkiverad',
'dashboard.search.placeholder': 'Sök resor eller platser',
'dashboard.search.label': 'Sök bland alla resor på titel, datum eller plats',
'dashboard.search.clear': 'Rensa sökning',
'dashboard.search.empty': 'Ingen resa matchar ”{query}”',
'dashboard.status.ongoing': 'Pågående',
'dashboard.status.today': 'Idag',
'dashboard.status.tomorrow': 'I morgon',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': "คัดลอก",
'dashboard.restore': "คืนค่า",
'dashboard.archived': "เก็บถาวรแล้ว",
'dashboard.search.placeholder': 'ค้นหาทริปหรือสถานที่',
'dashboard.search.label': 'ค้นหาทริปทั้งหมดตามชื่อ วันที่ หรือสถานที่',
'dashboard.search.clear': 'ล้างการค้นหา',
'dashboard.search.empty': 'ไม่มีทริปที่ตรงกับ “{query}”',
'dashboard.status.ongoing': "กำลังดำเนินการอยู่",
'dashboard.status.today': "วันนี้",
'dashboard.status.tomorrow': "พรุ่งนี้",
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'kopya',
'dashboard.restore': 'Geri al',
'dashboard.archived': 'Arşivlendi',
'dashboard.search.placeholder': 'Gezi veya yer ara',
'dashboard.search.label': 'Tüm gezilerde başlık, tarih veya yere göre ara',
'dashboard.search.clear': 'Aramayı temizle',
'dashboard.search.empty': '“{query}” ile eşleşen gezi yok',
'dashboard.status.ongoing': 'Devam ediyor',
'dashboard.status.today': 'Bugün',
'dashboard.status.tomorrow': 'Yarın',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'копія',
'dashboard.restore': 'Відновити',
'dashboard.archived': 'В архіві',
'dashboard.search.placeholder': 'Шукати поїздки або місця',
'dashboard.search.label': 'Шукати всі поїздки за назвою, датою або місцем',
'dashboard.search.clear': 'Очистити пошук',
'dashboard.search.empty': 'Немає поїздок за запитом «{query}»',
'dashboard.status.ongoing': 'Триває',
'dashboard.status.today': 'Сьогодні',
'dashboard.status.tomorrow': 'Завтра',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': 'sao chép',
'dashboard.restore': 'Khôi phục',
'dashboard.archived': 'Đã lưu trữ',
'dashboard.search.placeholder': 'Tìm chuyến đi hoặc địa điểm',
'dashboard.search.label': 'Tìm mọi chuyến đi theo tên, ngày hoặc địa điểm',
'dashboard.search.clear': 'Xóa tìm kiếm',
'dashboard.search.empty': 'Không có chuyến đi nào khớp với “{query}”',
'dashboard.status.ongoing': 'Đang thực hiện',
'dashboard.status.today': 'Hôm nay',
'dashboard.status.tomorrow': 'Ngày mai',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': '副本',
'dashboard.restore': '恢復',
'dashboard.archived': '已歸檔',
'dashboard.search.placeholder': '搜尋行程或地點',
'dashboard.search.label': '依標題、日期或地點搜尋所有行程',
'dashboard.search.clear': '清除搜尋',
'dashboard.search.empty': '沒有與「{query}」相符的行程',
'dashboard.status.ongoing': '進行中',
'dashboard.status.today': '今天',
'dashboard.status.tomorrow': '明天',
+4
View File
@@ -39,6 +39,10 @@ const dashboard: TranslationStrings = {
'dashboard.copySuffix': '副本',
'dashboard.restore': '恢复',
'dashboard.archived': '已归档',
'dashboard.search.placeholder': '搜索行程或地点',
'dashboard.search.label': '按标题、日期或地点搜索所有行程',
'dashboard.search.clear': '清除搜索',
'dashboard.search.empty': '没有与“{query}”匹配的行程',
'dashboard.status.ongoing': '进行中',
'dashboard.status.today': '今天',
'dashboard.status.tomorrow': '明天',
+13
View File
@@ -162,3 +162,16 @@ export const activeTripResponseSchema = z.object({
trip: activeTripSchema.nullable(),
});
export type ActiveTripResponse = z.infer<typeof activeTripResponseSchema>;
/**
* Trips whose places match a dashboard search (#2190), so "that diner in
* Philly" finds the trip it was on. A few matching place names per trip ride
* along for the card; title and date matching stays on the client.
*/
export const tripSearchResponseSchema = z.object({
matches: z.array(z.object({
trip_id: z.number(),
places: z.array(z.string()),
})),
});
export type TripSearchResponse = z.infer<typeof tripSearchResponseSchema>;