mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(dashboard): search every trip by title, date or a place on it (#2190)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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': 'غدًا',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ã',
|
||||
|
||||
@@ -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à',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Αύριο',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '明日',
|
||||
|
||||
@@ -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': '내일',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Завтра',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': "พรุ่งนี้",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Завтра',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '明天',
|
||||
|
||||
@@ -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': '明天',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user