diff --git a/client/e2e/help/fixtures.ts b/client/e2e/help/fixtures.ts index f52c640e3..9669e54b8 100644 --- a/client/e2e/help/fixtures.ts +++ b/client/e2e/help/fixtures.ts @@ -2,7 +2,8 @@ import sharp from 'sharp' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { gzipSync } from 'node:zlib' -import type { APIRequestContext, Page } from '@playwright/test' +import { request as apiRequest, type APIRequestContext, type Page } from '@playwright/test' +import { E2E_BASE_URL } from '../../playwright.config' import { OUT_DIR } from './guide' /** @@ -434,14 +435,1100 @@ export async function ensureDaysFixtures(api: APIRequestContext): Promise } // A hotel over the Tokyo nights. - if (!byName('Hotel Gracery Shinjuku')) { + // + // Idempotent on the booked night, not on the place, and loud on every step. A + // run once created the place and then lost the night without saying so, which + // left the day cards without their booked night and the first day without the + // only leg it has: the flight sits between its two stops, so they have none of + // their own. The picture that came out of that was of a feature that was not + // there, which is worse than a failed run. + const HOTEL = 'Hotel Gracery Shinjuku' + let hotelId = byName(HOTEL)?.id + if (!hotelId) { const created = await api.post(`/api/trips/${tripId}/places`, { - data: { name: 'Hotel Gracery Shinjuku', lat: 35.6946, lng: 139.7012, address: '1-19-1 Kabukicho, Shinjuku City, Tokyo' }, + data: { name: HOTEL, lat: 35.6946, lng: 139.7012, address: '1-19-1 Kabukicho, Shinjuku City, Tokyo' }, }) + if (!created.ok()) throw new Error(`could not create the hotel: ${created.status()} ${await created.text()}`) const place = (await created.json()) as { place?: { id: number }; id?: number } - const placeId = place.place?.id ?? place.id - await api.post(`/api/trips/${tripId}/accommodations`, { - data: { place_id: placeId, start_day_id: dayIds[0], end_day_id: dayIds[3], check_in: '15:00', check_out: '11:00', confirmation: 'GRC-7731' }, + hotelId = place.place?.id ?? place.id + if (!hotelId) throw new Error('the hotel came back without an id') + } + const nights = async (): Promise<{ place_id: number | null }[]> => { + const res = await api.get(`/api/trips/${tripId}/accommodations`) + if (!res.ok()) throw new Error(`could not read the booked nights: ${res.status()} ${await res.text()}`) + const body = (await res.json()) as { accommodations?: { place_id: number | null }[] } + return body.accommodations ?? [] + } + // Booked, then read back, then booked again if it is not there. + // + // The call answered 2xx and wrote nothing twice in a row, and the only symptom + // was a day card quietly missing the night the pictures are about, which cost + // two runs to notice. One retry covers it; a second silence is a real fault + // and stops the run rather than producing a picture of a feature that is not + // on screen. + for (let attempt = 1; ; attempt++) { + if ((await nights()).some(stay => stay.place_id === hotelId)) break + const booked = await api.post(`/api/trips/${tripId}/accommodations`, { + data: { place_id: hotelId, start_day_id: dayIds[0], end_day_id: dayIds[3], check_in: '15:00', check_out: '11:00', confirmation: 'GRC-7731' }, }) + if (!booked.ok()) throw new Error(`could not book the hotel: ${booked.status()} ${await booked.text()}`) + if (attempt === 2 && !(await nights()).some(stay => stay.place_id === hotelId)) { + throw new Error('the hotel night is not there after booking it twice') + } } } + +// ── Trip: place details ─────────────────────────────────────────────────────────── + +/** + * What the place card needs beyond the seed: a website on one place so the + * footer offers Open Website at all, a note on the first stop so the card can + * show Notes for this day, and two other travellers' star votes so the rating + * row has an average and a voter list rather than one lonely face. + * + * The votes are cast as the members themselves. A login on the shared context + * would set the trek_session cookie on it, and the server reads that cookie + * before the Authorization header, so every later write would silently be the + * admin's again, the same trap seed.ts documents at its collab block. A login + * that fails is skipped rather than thrown: a thinner rating row is worth less + * than losing every picture of this screen. + */ +export const PLACE_WEBSITE = { place: 'teamLab Planets', url: 'https://planets.teamlab.art/tokyo/' } +const STOP_NOTE = { + place: 'Senso-ji Temple', + notes: 'Enter through the Kaminarimon gate, not the side one. The Nakamise street is half the point.', +} +export const RATED_PLACE = 'Meiji Jingu' +const MEMBER_VOTES = [ + { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', rating: 5 }, + { username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', rating: 4 }, +] + +type FixturePlace = { + id: number + name: string + website?: string | null + ratings?: { username?: string | null }[] | null +} + +export async function ensurePlaceFixtures(api: APIRequestContext): Promise { + const { tripId, dayIds } = seededTrip() + const placesRes = await api.get(`/api/trips/${tripId}/places`) + const placesBody = (await placesRes.json()) as { places?: FixturePlace[] } | FixturePlace[] + const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? []) + const byName = (name: string) => places.find(p => p.name === name) + + // A website, so the footer has an Open Website button to point at. + const site = byName(PLACE_WEBSITE.place) + if (site && site.website !== PLACE_WEBSITE.url) { + const res = await api.put(`/api/trips/${tripId}/places/${site.id}`, { data: { website: PLACE_WEBSITE.url } }) + if (!res.ok()) throw new Error(`could not set the website on ${PLACE_WEBSITE.place}: ${res.status()} ${await res.text()}`) + } + + // A note that belongs to the stop rather than to the place, which is the one + // box of the card no other guide reaches. + const noted = byName(STOP_NOTE.place) + if (noted) { + const dayRes = await api.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`) + const dayBody = (await dayRes.json()) as { assignments?: { id: number; place_id: number; notes: string | null }[] } + const stop = (dayBody.assignments ?? []).find(a => a.place_id === noted.id) + if (stop && !stop.notes) { + const res = await api.put(`/api/trips/${tripId}/assignments/${stop.id}/notes`, { data: { notes: STOP_NOTE.notes } }) + if (!res.ok()) throw new Error(`could not note the ${STOP_NOTE.place} stop: ${res.status()} ${await res.text()}`) + } + } + + // Two members' votes, each cast from its own throwaway context. + const rated = byName(RATED_PLACE) + if (!rated) return + const voted = new Set((rated.ratings ?? []).map(r => r.username ?? '')) + for (const member of MEMBER_VOTES) { + if (voted.has(member.username)) continue + const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined }) + const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } }) + const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined + await anon.dispose() + if (!token) continue + const asMember = await apiRequest.newContext({ + baseURL: E2E_BASE_URL, + storageState: undefined, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }) + await asMember.put(`/api/trips/${tripId}/places/${rated.id}/rating`, { data: { rating: member.rating } }) + await asMember.dispose() + } +} + +// ── Trip: day details ───────────────────────────────────────────────────────────── + +/** The Tokyo hotel `ensureDaysFixtures` books over the first nights. */ +export const TOKYO_HOTEL = 'Hotel Gracery Shinjuku' + +/** + * The Kyoto hotel the booking guide puts on the Kyoto nights. It carries the + * Hotel category on purpose: the seeded places carry none, so the picker's + * category chips narrow its list to exactly this one. + */ +export const KYOTO_HOTEL = { + name: 'Hotel Kanra Kyoto', + lat: 34.9942, + lng: 135.7592, + address: '190 Kitamachi, Shimogyo Ward, Kyoto', + description: 'Machiya-style rooms a few minutes from Karasuma station.', +} + +/** The second booking of day 5: still pending, and tied to that day's stop. */ +export const EVENING_WALK = 'Fushimi Inari evening walk' + +/** + * What the day-detail guides need beyond the day fixtures: the Kyoto hotel as a + * place in the Hotel category, and a second booking on day 5 that hangs off the + * day's stop and is still pending, so the panel's Reservations block shows both + * a green and an amber row and one of them with its stop. + */ +export async function ensureDayDetailFixtures(api: APIRequestContext): Promise { + await ensureDaysFixtures(api) + const { tripId, dayIds } = seededTrip() + + const placesRes = await api.get(`/api/trips/${tripId}/places`) + const placesBody = (await placesRes.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? []) + const byName = (name: string) => places.find(p => p.name === name) + + if (!byName(KYOTO_HOTEL.name)) { + const catRes = await api.get('/api/categories') + const { categories } = (await catRes.json()) as { categories: { id: number; name: string }[] } + const hotel = categories.find(c => c.name === 'Hotel') + if (!hotel) throw new Error('the seeded Hotel category is missing') + const created = await api.post(`/api/trips/${tripId}/places`, { data: { ...KYOTO_HOTEL, category_id: hotel.id } }) + if (!created.ok()) throw new Error(`could not create "${KYOTO_HOTEL.name}": ${created.status()} ${await created.text()}`) + } + + const resRes = await api.get(`/api/trips/${tripId}/reservations`) + const resBody = (await resRes.json()) as { reservations?: { title: string }[] } | { title: string }[] + const reservations = Array.isArray(resBody) ? resBody : (resBody.reservations ?? []) + if (!reservations.some(r => r.title === EVENING_WALK)) { + const asgRes = await api.get(`/api/trips/${tripId}/days/${dayIds[4]}/assignments`) + const { assignments } = (await asgRes.json()) as { assignments: { id: number; place_id: number }[] } + const inari = assignments.find(a => a.place_id === byName('Fushimi Inari Taisha')?.id) + if (!inari) throw new Error('the Fushimi Inari stop of day 5 is missing') + const created = await api.post(`/api/trips/${tripId}/reservations`, { + data: { + title: EVENING_WALK, type: 'tour', status: 'pending', day_id: dayIds[4], + place_id: inari.place_id, assignment_id: inari.id, + reservation_time: '2026-09-16T17:00:00', reservation_end_time: '2026-09-16T19:30:00', + confirmation_number: 'FIT-3390', + }, + }) + if (!created.ok()) throw new Error(`could not create "${EVENING_WALK}": ${created.status()} ${await created.text()}`) + } +} + +// ── Trip: the map ───────────────────────────────────────────────────────────────── + +/** + * What the map guides need on the seeded trip beyond its places: a booking whose + * two ends both sit inside Japan, so the toolbar's "show all" has a route to + * draw next to the flight's arc. + */ +export async function ensureMapFixtures(api: APIRequestContext): Promise { + const { tripId } = seededTrip() + const res = await api.get(`/api/trips/${tripId}/reservations`) + const body = (await res.json()) as { reservations?: { title: string }[] } | { title: string }[] + const reservations = Array.isArray(body) ? body : (body.reservations ?? []) + if (reservations.some(r => r.title === 'Nozomi 21 Tokyo → Kyoto')) return + + const created = await api.post(`/api/trips/${tripId}/reservations`, { + data: { + title: 'Nozomi 21 Tokyo → Kyoto', type: 'train', status: 'confirmed', location: 'Tokyo Station', + reservation_time: '2026-09-16T08:30:00', reservation_end_time: '2026-09-16T10:45:00', confirmation_number: 'JR-58204', + metadata: { train_number: 'Nozomi 21', platform: '17', seat: '8A' }, + // Both ends need coordinates or the endpoints are dropped and the booking + // draws nothing at all. + endpoints: [ + { role: 'from', sequence: 0, name: 'Tokyo Station', lat: 35.6812, lng: 139.7671, timezone: 'Asia/Tokyo', local_date: '2026-09-16', local_time: '08:30' }, + { role: 'to', sequence: 1, name: 'Kyoto Station', lat: 34.9858, lng: 135.7588, timezone: 'Asia/Tokyo', local_date: '2026-09-16', local_time: '10:45' }, + ], + }, + }) + if (!created.ok()) throw new Error(`could not create the train: ${created.status()} ${await created.text()}`) +} + +// ── Trip: transports ────────────────────────────────────────────────────────────── + +/** The title TransitSearchPanel builds from the two ends, so a guide can find the journey again. */ +export const TRANSIT_JOURNEY = 'Arashiyama Bamboo Grove → Nishiki Market' + +/** + * What the transport guides need on the seeded trip beyond the day fixtures: + * a time on the second stop of day 1 so a new transport slots between the two, + * a booking that is still pending so the Pending group exists at all, and one + * stored public-transit connection so the tab has an Automated public transit + * group and a journey to open without asking Transitous first. + * + * The connection sits on day 6, the only day that carries two located stops of + * its own: the day's route tools, which is what draws a transit journey on the + * map, render for a day with two routable points. + */ +export async function ensureTransportFixtures(api: APIRequestContext): Promise { + await ensureDaysFixtures(api) + const { tripId, dayIds } = seededTrip() + + // A time on day 1's second stop. The first one already has 09:30, so a + // transport at midday lands between them instead of at the end of the day. + const placesRes = await api.get(`/api/trips/${tripId}/places`) + const placesBody = (await placesRes.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? []) + const teamLabId = places.find(p => p.name === 'teamLab Planets')?.id + const dayRes = await api.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`) + const dayBody = (await dayRes.json()) as { assignments?: { id: number; place_id: number }[] } + const teamLab = (dayBody.assignments ?? []).find(a => a.place_id === teamLabId) + if (teamLab) { + await api.put(`/api/trips/${tripId}/assignments/${teamLab.id}/time`, { data: { place_time: '13:00', end_time: '15:00' } }) + } + + const resRes = await api.get(`/api/trips/${tripId}/reservations`) + const resBody = (await resRes.json()) as { reservations?: { title: string }[] } | { title: string }[] + const reservations = Array.isArray(resBody) ? resBody : (resBody.reservations ?? []) + const has = (title: string) => reservations.some(r => r.title === title) + + // A car that is not confirmed yet, so the Pending group has a card in it. + if (!has('Rental car in Kyoto')) { + const created = await api.post(`/api/trips/${tripId}/reservations`, { + data: { + title: 'Rental car in Kyoto', type: 'car', status: 'pending', + day_id: dayIds[5], end_day_id: dayIds[7], + reservation_time: '2026-09-17T09:00', reservation_end_time: '2026-09-19T18:00', + confirmation_number: 'TYT-4417', + endpoints: [ + { role: 'from', sequence: 0, name: 'Kyoto Station Hachijo Exit', lat: 34.9835, lng: 135.759, timezone: 'Asia/Tokyo', local_date: '2026-09-17', local_time: '09:00' }, + { role: 'to', sequence: 1, name: 'Kyoto Station Hachijo Exit', lat: 34.9835, lng: 135.759, timezone: 'Asia/Tokyo', local_date: '2026-09-19', local_time: '18:00' }, + ], + }, + }) + if (!created.ok()) throw new Error(`could not create the rental car: ${created.status()} ${await created.text()}`) + } + + // One planned connection, in exactly the shape TransitSearchPanel saves: the + // list, the journey view and the map overlay all read metadata.transit. Its + // legs carry no geometry, which the overlay is happy with — a provider that + // sends no shape draws the journey through its stations. + if (!has(TRANSIT_JOURNEY)) { + const leg = ( + mode: string, line: string | null, duration: number, stops: number, + from: [string, string, string | null], to: [string, string, string | null], + extra: Record = {}, + ) => ({ + mode, line, duration, stops, + from: { name: from[0], time: from[1], track: from[2] }, + to: { name: to[0], time: to[1], track: to[2] }, + geometry: null, geometry_precision: 6, ...extra, + }) + const created = await api.post(`/api/trips/${tripId}/reservations`, { + data: { + title: TRANSIT_JOURNEY, type: 'transit', status: 'confirmed', + day_id: dayIds[5], end_day_id: dayIds[5], + reservation_time: '2026-09-17T09:48', reservation_end_time: '2026-09-17T10:25', + location: null, confirmation_number: null, notes: null, + metadata: { + transit: { + provider: 'transitous', duration: 2220, transfers: 1, walk_seconds: 1080, + legs: [ + leg('WALK', null, 540, 0, ['Arashiyama Bamboo Grove', '09:48', null], ['Saga-Arashiyama', '09:57', null]), + leg('REGIONAL_RAIL', 'JR Sagano Line', 660, 2, ['Saga-Arashiyama', '09:57', '2'], ['Nijō', '10:08', '1'], + { line_color: '#0072bc', line_text_color: '#ffffff', headsign: 'Kyoto', agency: 'JR West' }), + leg('SUBWAY', 'Tozai Line', 240, 1, ['Nijō', '10:12', '1'], ['Karasuma Oike', '10:16', '2'], + { line_color: '#d7003a', line_text_color: '#ffffff', headsign: 'Rokujizo', agency: 'Kyoto Municipal Subway' }), + leg('WALK', null, 540, 0, ['Karasuma Oike', '10:16', null], ['Nishiki Market', '10:25', null]), + ], + }, + }, + endpoints: [ + { role: 'from', sequence: 0, name: 'Arashiyama Bamboo Grove', lat: 35.017, lng: 135.6716, timezone: 'Asia/Tokyo', local_date: '2026-09-17', local_time: '09:48' }, + { role: 'stop', sequence: 1, name: 'Nijō', lat: 35.0109, lng: 135.7386, timezone: 'Asia/Tokyo', local_date: '2026-09-17', local_time: '10:08' }, + { role: 'to', sequence: 2, name: 'Nishiki Market', lat: 35.005, lng: 135.7649, timezone: 'Asia/Tokyo', local_date: '2026-09-17', local_time: '10:25' }, + ], + needs_review: false, + }, + }) + if (!created.ok()) throw new Error(`could not create the transit journey: ${created.status()} ${await created.text()}`) + } +} + +// ── Trip: bookings ──────────────────────────────────────────────────────────────── + +/** + * A one-page PDF the booking guides attach and link, written on first use. + * + * Hand-built rather than drawn with sharp: what the pictures show is the file + * NAME in the booking's Files list, never the page, and a booking confirmation + * is a document, not an image. Offsets are computed while the body is built, so + * the file is a valid PDF and not a blob with a `.pdf` on the end. + */ +export function bookingPdfFixture(id: 'teamlab-tickets' | 'kyoto-vouchers'): string { + const dir = path.join(process.cwd(), 'e2e', '.tmp') + mkdirSync(dir, { recursive: true }) + const file = path.join(dir, `${id}.pdf`) + if (existsSync(file)) return file + const title = id === 'teamlab-tickets' ? 'teamLab Planets - 2 tickets - TL-88213' : 'Kyoto vouchers - Autumn in Japan' + const stream = `BT /F1 16 Tf 64 760 Td (${title}) Tj ET` + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + ] + let body = '%PDF-1.4\n' + const offsets: number[] = [] + objects.forEach((object, i) => { + offsets.push(body.length) + body += `${i + 1} 0 obj\n${object}\nendobj\n` + }) + const startxref = body.length + body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + for (const offset of offsets) body += `${String(offset).padStart(10, '0')} 00000 n \n` + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${startxref}\n%%EOF\n` + writeFileSync(file, Buffer.from(body, 'latin1')) + return file +} + +/** The hotel confirmation the import guide hands to the parser; written on first use. */ +export function bookingEmlFixture(): string { + const dir = path.join(process.cwd(), 'e2e', '.tmp') + mkdirSync(dir, { recursive: true }) + const file = path.join(dir, 'gracery-confirmation.eml') + if (existsSync(file)) return file + writeFileSync( + file, + [ + 'From: reservations@example-hotels.test', + 'To: admin@trek.local', + 'Subject: Your reservation is confirmed - Hotel Granvia Kyoto', + 'Date: Tue, 1 Sep 2026 09:12:00 +0900', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Booking reference: GRK-40218', + 'Hotel Granvia Kyoto, Shiokoji-sagaru, Karasuma-dori, Shimogyo-ku, Kyoto', + 'Check-in: Wednesday 16 September 2026, from 15:00', + 'Check-out: Saturday 19 September 2026, until 11:00', + 'Guests: 2 Room: Twin, non-smoking', + 'Total: JPY 84000', + '', + ].join('\r\n'), + 'utf8', + ) + return file +} + +/** One card of every type the Bookings tab can hold, by title. */ +interface BookingFixture { + title: string + type: string + status: string + /** A trip place to hang the booking on, looked up by name; null for a standalone one. */ + place: string | null + reservation_time: string + reservation_end_time: string | null + confirmation_number: string + location: string + url: string | null + notes: string | null +} + +const BOOKING_FIXTURES: BookingFixture[] = [ + { + // No place on purpose: picking one is what `link-booking` does. + title: 'teamLab Planets timed entry', type: 'event', status: 'pending', place: null, + reservation_time: '2026-09-12T14:00:00', reservation_end_time: '2026-09-12T16:00:00', + confirmation_number: 'TL-88213', location: '6-1-16 Toyosu, Koto City, Tokyo', + url: 'https://www.teamlab.art/e/planets/', + notes: 'Barefoot from the entrance. Bring shorts, the water room is knee-deep.', + }, + { + title: 'Fushimi Inari night walk', type: 'tour', status: 'confirmed', place: 'Fushimi Inari Taisha', + reservation_time: '2026-09-16T18:30:00', reservation_end_time: '2026-09-16T21:00:00', + confirmation_number: 'KY-4471', location: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto', + url: null, notes: null, + }, + { + title: 'Kyoto Cycling Tour', type: 'tour', status: 'pending', place: null, + reservation_time: '2026-09-18T09:30:00', reservation_end_time: '2026-09-18T13:00:00', + confirmation_number: 'KCT-1177', location: 'Kyoto Station, Karasuma exit', + url: null, notes: null, + }, + { + title: 'Haneda Airport P4', type: 'parking', status: 'pending', place: null, + reservation_time: '2026-09-12T10:00:00', reservation_end_time: '2026-09-21T22:00:00', + confirmation_number: 'HP4-2209', location: 'Haneda Airport Terminal 3, Ota City, Tokyo', + url: null, notes: null, + }, + { + title: 'Luggage forwarding to Kyoto', type: 'other', status: 'pending', place: null, + reservation_time: '2026-09-16T09:00:00', reservation_end_time: null, + confirmation_number: 'YMT-30915', location: 'Takkyubin counter, Shinjuku', + url: null, notes: 'Two suitcases, delivered by 17:00 the next day.', + }, +] + +/** The hotel `booking-hotel` books; a trip place with no stay of its own yet. */ +export const HOTEL_TO_BOOK = { + name: 'Hotel Granvia Kyoto', + lat: 34.9855, + lng: 135.7587, + address: 'Shiokoji-sagaru, Karasuma-dori, Shimogyo-ku, Kyoto', +} + +/** The trip file `booking-files` links; it must stay attached to nothing. */ +export const UNATTACHED_FILE = 'kyoto-vouchers.pdf' + +/** + * What the Bookings tab needs beyond the seed. + * + * The seed's one reservation is the LH716 flight, and a flight is a transport: + * on the bare seed this tab is its empty state, so every picture on it needs a + * fixture. `ensureDaysFixtures` already makes the two that the day cards want + * as well, the Gracery stay and the Nishiki lunch, and they are exactly the + * accommodation and the restaurant this tab wants, so it is called rather than + * copied. On top of it: an event, two tours (one with travellers), a parking + * and an "other", which is one card of every type the tab can hold, four + * pending and three confirmed; a second hotel as a trip place with no stay, + * for `booking-hotel` to book without disturbing the Gracery one; and an + * unattached document, without which Link existing file is not rendered. + */ +export async function ensureBookingsFixtures(api: APIRequestContext): Promise { + await ensureDaysFixtures(api) + const { tripId, memberIds } = seededTrip() + + const placesRes = await api.get(`/api/trips/${tripId}/places`) + const placesBody = (await placesRes.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? []) + const placeId = (name: string) => places.find(p => p.name === name)?.id ?? null + if (!placeId(HOTEL_TO_BOOK.name)) { + const created = await api.post(`/api/trips/${tripId}/places`, { data: HOTEL_TO_BOOK }) + if (!created.ok()) throw new Error(`could not create "${HOTEL_TO_BOOK.name}": ${created.status()} ${await created.text()}`) + } + + const listRes = await api.get(`/api/trips/${tripId}/reservations`) + const listBody = (await listRes.json()) as { reservations?: { id: number; title: string }[] } | { id: number; title: string }[] + const existing = Array.isArray(listBody) ? listBody : (listBody.reservations ?? []) + for (const booking of BOOKING_FIXTURES) { + if (existing.some(r => r.title === booking.title)) continue + const { place, ...rest } = booking + const created = await api.post(`/api/trips/${tripId}/reservations`, { + data: { ...rest, place_id: place ? placeId(place) : null }, + }) + if (!created.ok()) throw new Error(`could not create "${booking.title}": ${created.status()} ${await created.text()}`) + // The avatar filter in the toolbar only exists once a booking names someone. + if (booking.title === 'Fushimi Inari night walk') { + const { reservation } = (await created.json()) as { reservation: { id: number } } + const assigned = await api.put(`/api/trips/${tripId}/reservations/${reservation.id}/travelers`, { + data: { user_ids: [1, memberIds[0]] }, + }) + if (!assigned.ok()) throw new Error(`could not assign travellers: ${assigned.status()} ${await assigned.text()}`) + } + } + + const filesRes = await api.get(`/api/trips/${tripId}/files`) + const { files = [] } = (await filesRes.json()) as { files?: { original_name: string }[] } + if (!files.some(f => f.original_name === UNATTACHED_FILE)) { + const uploaded = await api.post(`/api/trips/${tripId}/files`, { + multipart: { + file: { name: UNATTACHED_FILE, mimeType: 'application/pdf', buffer: readFileSync(bookingPdfFixture('kyoto-vouchers')) }, + description: 'Kyoto vouchers', + }, + }) + if (!uploaded.ok()) throw new Error(`could not upload ${UNATTACHED_FILE}: ${uploaded.status()} ${await uploaded.text()}`) + } +} + +// ── Trip: lists ─────────────────────────────────────────────────────────────────── + +/** + * What the Lists guides need beyond the seed. + * + * Two things are missing from the seeded trip. ApplyTemplateButton renders + * nothing at all while no template exists, so `Apply template` would have no + * target to ring; and all three seeded tasks are unchecked, unassigned and + * (today being after their due dates) overdue, which leaves My Tasks, Done and + * the progress card at zero in every picture of the to-do sidebar. One + * template and two more tasks fix both. Idempotent: each thing is looked up by + * name first, so a re-run against a warm database changes nothing. + */ +export const LISTS_TEMPLATE = { + name: 'Japan autumn basics', + categories: [ + { name: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag'] }, + { name: 'Tech', items: ['SIM card', 'Plug adapter'] }, + ], +} + +interface ListsTodo { + name: string + category: string + due_date?: string + priority?: number + /** Assigned to whoever runs the capture, so the My Tasks filter has a row. */ + mine?: boolean + /** Ticked off afterwards, so Done and the progress card are not empty. */ + done?: boolean +} + +const LISTS_TODOS: ListsTodo[] = [ + { name: 'Confirm ryokan check-in time', category: 'Before departure', due_date: '2026-09-25', priority: 3, mine: true }, + { name: 'Buy a Suica top-up', category: 'On arrival', done: true }, +] + +export async function ensureListsFixtures(api: APIRequestContext): Promise { + const { tripId } = seededTrip() + + const templates = await listOf<{ id: number; name: string }>(api, '/api/admin/packing-templates', 'templates') + if (!templates.some(t => t.name === LISTS_TEMPLATE.name)) { + const created = await api.post('/api/admin/packing-templates', { data: { name: LISTS_TEMPLATE.name } }) + if (!created.ok()) throw new Error(`could not create the lists template: ${created.status()} ${await created.text()}`) + const body = (await created.json()) as { id?: number; template?: { id: number } } + const templateId = body.id ?? body.template?.id + if (!templateId) throw new Error('the lists template came back without an id') + for (const cat of LISTS_TEMPLATE.categories) { + const catRes = await api.post(`/api/admin/packing-templates/${templateId}/categories`, { data: { name: cat.name } }) + if (!catRes.ok()) throw new Error(`could not add template list "${cat.name}": ${catRes.status()} ${await catRes.text()}`) + const category = (await catRes.json()) as { id?: number; category?: { id: number } } + const catId = category.id ?? category.category?.id + if (!catId) throw new Error(`template list "${cat.name}" came back without an id`) + for (const name of cat.items) { + const itemRes = await api.post(`/api/admin/packing-templates/${templateId}/categories/${catId}/items`, { data: { name } }) + if (!itemRes.ok()) throw new Error(`could not add template item "${name}": ${itemRes.status()} ${await itemRes.text()}`) + } + } + } + + const membersRes = await api.get(`/api/trips/${tripId}/members`) + if (!membersRes.ok()) throw new Error(`could not read the trip members: ${membersRes.status()} ${await membersRes.text()}`) + const { current_user_id: me } = (await membersRes.json()) as { current_user_id?: number } + + const tasks = await listOf<{ id: number; name: string }>(api, `/api/trips/${tripId}/todo`, 'items') + for (const todo of LISTS_TODOS) { + if (tasks.some(t => t.name === todo.name)) continue + const created = await api.post(`/api/trips/${tripId}/todo`, { + data: { + name: todo.name, + category: todo.category, + due_date: todo.due_date ?? null, + priority: todo.priority ?? 0, + assigned_user_id: todo.mine ? (me ?? null) : null, + }, + }) + if (!created.ok()) throw new Error(`could not create the task "${todo.name}": ${created.status()} ${await created.text()}`) + if (!todo.done) continue + const { item } = (await created.json()) as { item: { id: number } } + const ticked = await api.put(`/api/trips/${tripId}/todo/${item.id}`, { data: { checked: true } }) + if (!ticked.ok()) throw new Error(`could not tick "${todo.name}" off: ${ticked.status()} ${await ticked.text()}`) + } +} + +// ── Trip: costs ─────────────────────────────────────────────────────────────────── + +/** + * The one thing the Costs tab of the seeded trip is missing: an expense nobody + * has paid for. All five seeded expenses have a payer, so Outstanding amount + * reads zero, no row carries the Unfinished pill, and two of the four summary + * cards say the same thing. This adds a counter fare the group has not booked + * yet: it counts into Total trip spend, stays out of every balance, and gives + * the screen its fourth state. It stays for every guide; what a guide creates + * itself, that guide deletes again. + * + * `payers` is left out on purpose. The server only re-derives total_price from + * the payers when there is at least one, so the total survives and the item is + * unfinished. + */ +const UNPAID_EXPENSE = { + name: 'Airport transfer (Narita Express)', + category: 'transport', + total_price: 12000, + currency: 'JPY', + expense_date: '2026-09-13', + note: 'Nobody has booked it yet, so this is the counter fare for three.', +} + +export async function ensureCostsFixtures(api: APIRequestContext): Promise { + const { tripId, memberIds } = seededTrip() + const res = await api.get(`/api/trips/${tripId}/budget`) + const { items } = (await res.json()) as { items: { name: string }[] } + if (items.some(i => i.name === UNPAID_EXPENSE.name)) return + const created = await api.post(`/api/trips/${tripId}/budget`, { + data: { ...UNPAID_EXPENSE, member_ids: [1, ...memberIds] }, + }) + if (!created.ok()) throw new Error(`could not create "${UNPAID_EXPENSE.name}": ${created.status()} ${await created.text()}`) +} + +// ── Trip: files ─────────────────────────────────────────────────────────────────── + +/** + * What the Files guides act on. The seed creates no document at all, so without + * this the tab is an empty state in every picture. Nothing in the repository may + * carry a real ticket or someone's photograph, so the whole set is drawn here on + * first use, the way `coverFixture` draws its cover: the pictures from an SVG + * through sharp, the PDFs assembled by hand, the text files written out. + */ + +/** One-page PDFs. The body is what the reader would see if they opened the file. */ +const FILE_PDFS = { + 'JR-Pass-voucher': { + title: 'Japan Rail Pass', + lines: ['Exchange order 7714-20', 'Ordinary, 14 days, 2 adults', 'Exchange at Haneda T3 arrivals, JR desk', 'Valid from the day it is exchanged'], + }, + 'LH716-boarding-pass': { + title: 'Boarding pass LH716', + lines: ['Frankfurt FRA to Tokyo Haneda HND', '12 Sep 2026, boarding 12:20, gate A34', 'Seat 34K, window', 'Booking reference LH-4QK2PZ'], + }, + 'old-draft-itinerary': { + title: 'Itinerary, first draft', + lines: ['Day 1 Asakusa and the river', 'Day 2 Shibuya, Meiji Jingu', 'Day 3 free', 'Superseded by the plan in TREK'], + }, + 'hakone-ryokan-confirmation': { + title: 'Ryokan booking', + lines: ['Two nights, half board', 'Kaiseki served at 18:30', 'Check in from 15:00, check out 11:00', 'Confirmation RY-4471'], + }, + 'kyoto-bus-pass': { + title: 'Kyoto city bus pass', + lines: ['One day, all city lines', 'Show the pass when leaving the bus', 'Not valid on the Kyoto Bus express'], + }, +} as const + +/** Pictures, drawn flat so they read at thumbnail size as well as full screen. */ +const FILE_IMAGES = { + 'nishiki-market': { + ext: 'jpg', + svg: ` + + + ${[0, 1, 2, 3, 4].map(i => ``).join('\n ')} + `, + }, + 'hakone-ryokan': { + ext: 'jpg', + svg: ` + + + + + ${[0, 1, 2, 3].map(i => ``).join('\n ')} + `, + }, + 'jr-pass-map': { + ext: 'png', + svg: ` + + + ${[[180, 700], [520, 700], [760, 460], [1120, 460], [1420, 240], [180, 250], [640, 250], [900, 500], [1420, 500]].map(([x, y]) => ``).join('\n ')}`, + }, + arashiyama: { + ext: 'jpg', + svg: ` + ${Array.from({ length: 16 }, (_, i) => ``).join('\n ')} + + `, + }, +} as const + +/** The two plain-text documents; written out rather than drawn. */ +const FILE_TEXTS = { + 'travel-notes.md': `# Travel notes + +What the rail passes cover, and what they do not. + +- Activate the Japan Rail Pass at the counter in the arrivals hall, not before +- Reserve the Nozomi seats a day ahead; the pass does not cover Nozomi itself +- Keep the paper voucher, the gates do not read the app + +## Legs + +| Leg | Train | Time | +| --- | --- | --- | +| Tokyo to Kyoto | Nozomi 21 | 2h15 | +| Kyoto to Osaka | Local | 0h30 | + +## Left to book + +1. The bus up to Arashiyama +2. A table at Nishiki for the last evening +`, + 'tokyo-metro-lines.csv': `line,colour,from,to,transfers +Ginza,orange,Asakusa,Shibuya,9 +Marunouchi,red,Ogikubo,Ikebukuro,11 +Hibiya,grey,Naka-Meguro,Kita-Senju,8 +Oedo,magenta,Hikarigaoka,Tochomae,12 +Yamanote,green,Tokyo,Tokyo,29 +`, +} as const + +/** The note the seed writes; its attachment is what puts the Collab Notes tab on the screen. */ +const COLLAB_NOTE = 'Rail passes' +const COLLAB_ATTACHMENT = 'jr-pass-map.png' +/** Uploaded and then thrown away, so the trash is never empty and Empty Trash is always there. */ +const TRASHED = { name: 'hakone-ryokan-confirmation.pdf', description: '2 nights, kaiseki at 18:30' } + +/** + * The documents of the trip, in the order they are uploaded: the list is sorted + * newest first, so the last one here stands at the top of the screen. + * + * `travel-notes.md` goes up as `application/octet-stream` on purpose. The + * Documents tab counts by file name (`.doc`, `.xls`, `.txt`, `.csv`) but filters + * by MIME type (`word`, `excel`, `text`), so a markdown file sent as `text/*` + * would be listed under a badge that says 0. As an octet-stream it is on neither + * side of that split, and the preview finds it by its extension anyway. + */ +const TRIP_DOCUMENTS: { name: string; mimeType: string; from: () => string | Promise; place?: string }[] = [ + { name: 'JR-Pass-voucher.pdf', mimeType: 'application/pdf', from: () => filesPdfFixture('JR-Pass-voucher') }, + { name: 'LH716-boarding-pass.pdf', mimeType: 'application/pdf', from: () => filesPdfFixture('LH716-boarding-pass') }, + { name: 'old-draft-itinerary.pdf', mimeType: 'application/pdf', from: () => filesPdfFixture('old-draft-itinerary') }, + { name: 'tokyo-metro-lines.csv', mimeType: 'text/csv', from: () => filesTextFixture('tokyo-metro-lines.csv') }, + { name: 'travel-notes.md', mimeType: 'application/octet-stream', from: () => filesTextFixture('travel-notes.md') }, + { name: 'hakone-ryokan.jpg', mimeType: 'image/jpeg', from: () => filesImageFixture('hakone-ryokan') }, + { name: 'nishiki-market.jpg', mimeType: 'image/jpeg', from: () => filesImageFixture('nishiki-market'), place: 'Nishiki Market' }, +] + +const tmpDir = (): string => { + const dir = path.join(process.cwd(), 'e2e', '.tmp') + mkdirSync(dir, { recursive: true }) + return dir +} + +/** + * A one-page PDF, written by hand because no PDF library is a dependency here. + * Five objects and an xref table whose offsets are counted off the assembled + * buffer; everything in it is ASCII, so bytes and characters are the same thing. + */ +export function filesPdfFixture(id: keyof typeof FILE_PDFS): string { + const file = path.join(tmpDir(), `${id}.pdf`) + if (existsSync(file)) return file + const { title, lines } = FILE_PDFS[id] + const escape = (s: string) => s.replace(/([\\()])/g, '\\$1') + const content = [ + `BT /F1 26 Tf 64 752 Td (${escape(title)}) Tj ET`, + ...lines.map((line, i) => `BT /F1 13 Tf 64 ${700 - i * 26} Td (${escape(line)}) Tj ET`), + ].join('\n') + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ] + let pdf = '%PDF-1.4\n' + const offsets: number[] = [] + objects.forEach((body, i) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')) + pdf += `${i + 1} 0 obj\n${body}\nendobj\n` + }) + const startxref = Buffer.byteLength(pdf, 'latin1') + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + for (const offset of offsets) pdf += `${String(offset).padStart(10, '0')} 00000 n \n` + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${startxref}\n%%EOF\n` + writeFileSync(file, pdf, 'latin1') + return file +} + +/** A 1600×900 picture from its SVG, as `coverFixture` does; drawn on first use. */ +export async function filesImageFixture(id: keyof typeof FILE_IMAGES): Promise { + const { ext, svg } = FILE_IMAGES[id] + const file = path.join(tmpDir(), `${id}.${ext}`) + if (existsSync(file)) return file + const document = `\n ${svg}\n` + const image = sharp(Buffer.from(document)) + await (ext === 'png' ? image.png() : image.jpeg({ quality: 86 })).toFile(file) + return file +} + +/** The markdown and the CSV, written out on first use. */ +export function filesTextFixture(id: keyof typeof FILE_TEXTS): string { + const file = path.join(tmpDir(), id) + if (existsSync(file)) return file + writeFileSync(file, FILE_TEXTS[id]) + return file +} + +async function uploadTripFile( + api: APIRequestContext, + tripId: number, + doc: { name: string; mimeType: string; from: () => string | Promise }, + fields: Record = {}, +): Promise { + const res = await api.post(`/api/trips/${tripId}/files`, { + multipart: { + file: { name: doc.name, mimeType: doc.mimeType, buffer: readFileSync(await doc.from()) }, + ...fields, + }, + }) + if (!res.ok()) throw new Error(`could not upload "${doc.name}": ${res.status()} ${await res.text()}`) + const { file } = (await res.json()) as { file: { id: number } } + return file.id +} + +export async function ensureFilesFixtures(api: APIRequestContext): Promise { + // The Assign File dialog only shows a Booking section beside Transport once + // the trip has a reservation that is not a transport, and the day fixtures + // make one. Their hotel also gives the dialog a place under Unassigned. + await ensureDaysFixtures(api) + + const { tripId } = seededTrip() + const placesRes = await api.get(`/api/trips/${tripId}/places`) + const placesBody = (await placesRes.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? []) + + const namesIn = async (trash: boolean): Promise => { + const res = await api.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`) + const body = (await res.json()) as { files?: { original_name: string }[] } + return (body.files ?? []).map(f => f.original_name) + } + // Both lists, so a document the fixture threw away is not uploaded a second time. + const present = new Set([...(await namesIn(false)), ...(await namesIn(true))]) + + for (const doc of TRIP_DOCUMENTS) { + if (present.has(doc.name)) continue + const placeId = doc.place ? places.find(p => p.name === doc.place)?.id : undefined + await uploadTripFile(api, tripId, doc, placeId ? { place_id: String(placeId) } : {}) + } + + // The Collab Notes tab and the "From Collab Notes" badge exist only while a + // note carries an attachment. It is an ordinary trip file with a note on it, + // so it shows up in this list too. + if (!present.has(COLLAB_ATTACHMENT)) { + const notesRes = await api.get(`/api/trips/${tripId}/collab/notes`) + const { notes = [] } = (await notesRes.json()) as { notes?: { id: number; title: string }[] } + const note = notes.find(n => n.title === COLLAB_NOTE) + if (!note) throw new Error(`fixture collab note "${COLLAB_NOTE}" is missing`) + const attached = await api.post(`/api/trips/${tripId}/collab/notes/${note.id}/files`, { + multipart: { + file: { name: COLLAB_ATTACHMENT, mimeType: 'image/png', buffer: readFileSync(await filesImageFixture('jr-pass-map')) }, + }, + }) + if (!attached.ok()) throw new Error(`could not attach "${COLLAB_ATTACHMENT}": ${attached.status()} ${await attached.text()}`) + } + + // One document in the trash from the start: without it the trash view is an + // empty state and Empty Trash, which one step is a picture of, is not drawn. + if (!present.has(TRASHED.name)) { + const id = await uploadTripFile( + api, + tripId, + { name: TRASHED.name, mimeType: 'application/pdf', from: () => filesPdfFixture('hakone-ryokan-confirmation') }, + { description: TRASHED.description }, + ) + const deleted = await api.delete(`/api/trips/${tripId}/files/${id}`) + if (!deleted.ok()) throw new Error(`could not put "${TRASHED.name}" in the trash: ${deleted.status()} ${await deleted.text()}`) + } +} + +// ── Trip: collab ────────────────────────────────────────────────────────────────── + +/** + * Three links the trip runs on. The seed creates none, so without them the + * Links panel is an empty state in the hero and in every Links picture. + */ +export const COLLAB_LINKS = [ + { title: 'Japan Rail Pass activation', url: 'https://www.japanrailpass.net/en/' }, + { title: 'teamLab Planets tickets', url: 'https://www.teamlab.art/e/planets/' }, + { title: 'Shared photo album', url: 'https://photos.example/autumn-in-japan' }, +] + +/** + * A poll to spend. Closing one cannot be undone (there is no reopen route), so + * the guide about closing must not be pointed at either of the seeded polls. + * `ensureCollabFixtures` also casts one vote on it: a closed poll nobody voted + * on has no winning option, and `isWinner` in `CollabPolls.tsx` needs a count + * above zero before it tints one green, which is what the guide's text says. + */ +export const COLLAB_SPARE_POLL = { + question: 'Rent a car for the Hakone leg?', + options: ['Yes, a small one', 'No, trains only'], + multiple: false, +} + +/** + * The two stops What's Next reads. Both places are already on the trip, so this + * adds no row to the places column that the other trip screens photograph: it + * only puts them on the last day as well. One carries a time, one stays untimed + * and reads TBD, which is also the fallback when the run starts too late in the + * evening for a time on today to still be ahead. + */ +export const WHATS_NEXT_STOPS = { timed: 'Nishiki Market', untimed: 'Fushimi Inari Taisha' } + +/** + * What the Collab guides need beyond the seed, which already holds the three + * notes, the two polls and the seven message conversation. Safe to run again: + * every collection is read first and only what is missing is written, except + * the spare poll, which is always made fresh because closing it is final. + */ +export async function ensureCollabFixtures(api: APIRequestContext): Promise { + const { tripId, dayIds } = seededTrip() + const collab = `/api/trips/${tripId}/collab` + + const linksRes = await api.get(`${collab}/links`) + const { links } = (await linksRes.json()) as { links: { title: string }[] } + for (const link of COLLAB_LINKS) { + if (links.some(l => l.title === link.title)) continue + const created = await api.post(`${collab}/links`, { data: link }) + if (!created.ok()) throw new Error(`could not add the link "${link.title}": ${created.status()} ${await created.text()}`) + } + + // The spare poll is thrown away and made again rather than reused: closing is + // irreversible, so one left behind by a run that stopped short would already + // be closed and the guide could not close it. Fresh means open and unvoted. + const pollsRes = await api.get(`${collab}/polls`) + const { polls } = (await pollsRes.json()) as { polls: { id: number; question: string }[] } + for (const old of polls.filter(p => p.question === COLLAB_SPARE_POLL.question)) { + await api.delete(`${collab}/polls/${old.id}`) + } + const spareRes = await api.post(`${collab}/polls`, { data: COLLAB_SPARE_POLL }) + if (!spareRes.ok()) throw new Error(`could not create the spare poll: ${spareRes.status()} ${await spareRes.text()}`) + const { poll: spare } = (await spareRes.json()) as { poll: { id: number } } + // One vote on it, so the poll the close-poll guide closes has a winner to tint. + const voted = await api.post(`${collab}/polls/${spare.id}/vote`, { data: { option_index: 0 } }) + if (!voted.ok()) throw new Error(`could not vote on the spare poll: ${voted.status()} ${await voted.text()}`) + + // What's Next lists a stop on a later day, or one on today whose start has not + // passed yet. The trip's last day IS today on the seed's dates, so both stops + // go there and the time is computed from the clock at fixture time. + const lastDay = dayIds[dayIds.length - 1] + const placesRes = await api.get(`/api/trips/${tripId}/places`) + const placesBody = (await placesRes.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? []) + const dayRes = await api.get(`/api/trips/${tripId}/days/${lastDay}/assignments`) + const { assignments } = (await dayRes.json()) as { assignments: { id: number; place_id: number }[] } + + const putOnLastDay = async (name: string): Promise => { + const place = places.find(p => p.name === name) + if (!place) throw new Error(`fixture place "${name}" is not on the seeded trip`) + const already = assignments.find(a => a.place_id === place.id) + if (already) return already.id + const created = await api.post(`/api/trips/${tripId}/days/${lastDay}/assignments`, { data: { place_id: place.id } }) + if (!created.ok()) throw new Error(`could not put "${name}" on the last day: ${created.status()} ${await created.text()}`) + const { assignment } = (await created.json()) as { assignment: { id: number } } + return assignment.id + } + + const timedId = await putOnLastDay(WHATS_NEXT_STOPS.timed) + await putOnLastDay(WHATS_NEXT_STOPS.untimed) + + const pad = (n: number): string => String(n).padStart(2, '0') + const clock = (d: Date): string => `${pad(d.getHours())}:${pad(d.getMinutes())}` + const now = new Date() + const from = new Date(now.getTime() + 45 * 60_000) + const until = new Date(now.getTime() + 165 * 60_000) + // A start that has already passed drops the stop out of the panel, and a time + // after midnight belongs to a day the trip does not have. Both cases end in no + // time at all: an untimed stop on today is always listed, and reads TBD. + const stillToday = until.getDate() === now.getDate() + // Written on every run, not only on the first: a time left over from an older + // run would be in the past by now. + const timed = await api.put(`/api/trips/${tripId}/assignments/${timedId}/time`, { + data: stillToday ? { place_time: clock(from), end_time: clock(until) } : { place_time: null, end_time: null }, + }) + if (!timed.ok()) throw new Error(`could not time the What's Next stop: ${timed.status()} ${await timed.text()}`) +} + +// ── Trip: road trip ─────────────────────────────────────────────────────────────── + +/** + * The drive out of Tokyo. Every seeded day is a walk across one city, and a ten + * kilometre leg has no alternatives worth offering, never runs a tank dry and + * gives the corridor search almost nothing to find. Day 4 is empty in the seed, + * so it gets a real one: about 95 km of Tomei expressway, which is long enough + * for the router to have opinions about it and long enough to matter to a range. + */ +const ROADTRIP_DRIVE = [ + { + name: 'Shinjuku Gyoen', + lat: 35.6852, + lng: 139.71, + address: '11 Naitomachi, Shinjuku City, Tokyo', + description: 'The garden the drive out of Tokyo starts from.', + }, + { + name: 'Hakone Shrine', + lat: 35.2046, + lng: 139.0256, + address: '80-1 Motohakone, Hakone, Kanagawa', + description: 'Torii standing in the water at the edge of Lake Ashi.', + }, +] + +/** + * What the road trip guides act on beyond the seed. + * + * Four things the seed does not have: the addon itself, which is off by default + * and without which there is no mode switch, no rail and no /roadtrip route at + * all; the drive above; a track and a day to fit it to, for the guide that + * follows one; and the driving settings, so the card carries badges and the + * rail has a point where the tank runs out. + * + * Deliberately NOT set here: the daily travel times. They force Connect the + * days on, which routes the night between every pair of days and puts extra + * rows and extra days into every other guide's pictures. `roadtrip-day-window` + * sets them and clears them again itself. + * + * Everything is idempotent, and the guides call it again to undo what they + * changed, so the preferences are written in full rather than only when absent. + */ +export async function ensureRoadtripFixtures(api: APIRequestContext): Promise { + const { tripId, dayIds } = seededTrip() + + // Instance-wide and admin-only; the seeded user is the admin. Every + // /roadtrip route 404s without it, so this comes first. + const addon = await api.put('/api/admin/addons/roadtrip', { data: { enabled: true } }) + if (!addon.ok()) throw new Error(`could not enable the Road trip addon: ${addon.status()} ${await addon.text()}`) + + const placesOf = async (): Promise<{ id: number; name: string }[]> => { + const res = await api.get(`/api/trips/${tripId}/places`) + const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + return Array.isArray(body) ? body : (body.places ?? []) + } + const pin = async (dayId: number, placeId: number): Promise => { + const res = await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`) + const body = (await res.json()) as { assignments?: { place_id: number }[] } + if ((body.assignments ?? []).some(a => a.place_id === placeId)) return + const created = await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: placeId } }) + if (!created.ok()) { + throw new Error(`could not put place ${placeId} on day ${dayId}: ${created.status()} ${await created.text()}`) + } + } + + for (const place of ROADTRIP_DRIVE) { + if ((await placesOf()).some(p => p.name === place.name)) continue + const created = await api.post(`/api/trips/${tripId}/places`, { data: place }) + if (!created.ok()) throw new Error(`could not create "${place.name}": ${created.status()} ${await created.text()}`) + } + const places = await placesOf() + // In this order: the drive runs out of the city, not into it. + for (const place of ROADTRIP_DRIVE) { + const found = places.find(p => p.name === place.name) + if (!found) throw new Error(`fixture place "${place.name}" is missing`) + await pin(dayIds[3], found.id) + } + + // The loop, and the two stops the day is for, so the track reads as one along + // this day rather than as one a long way off it. + await ensureTrack(api, tripId, 'arashiyama-loop') + const withTrack = await placesOf() + for (const name of ['Togetsukyo Bridge', 'Okochi Sanso Villa']) { + const found = withTrack.find(p => p.name === name) + if (!found) throw new Error(`the imported track's waypoint "${name}" is missing`) + await pin(dayIds[6], found.id) + } + + const prefs = await api.put(`/api/trips/${tripId}/roadtrip/preferences`, { + data: { + roadtrip_vehicle: 'combustion', + roadtrip_range_km: 70, + roadtrip_fill_percent: 80, + roadtrip_leg_minutes: 90, + roadtrip_day_minutes: 240, + roadtrip_day_start: '', + roadtrip_day_end: '', + roadtrip_day_end_mode: 'route', + }, + }) + if (!prefs.ok()) throw new Error(`could not write the driving settings: ${prefs.status()} ${await prefs.text()}`) +} diff --git a/client/e2e/help/guide.ts b/client/e2e/help/guide.ts index 254273ebb..fbefb59a8 100644 --- a/client/e2e/help/guide.ts +++ b/client/e2e/help/guide.ts @@ -29,6 +29,24 @@ export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'help-media') */ export const VIEWPORT = { width: 1920, height: 1080 } +/** + * The day every picture is taken on. + * + * The seeded trip runs to 2026-09-21, and a picture of it has to look the same + * whoever runs this and whenever: a day later the trip is over, My Trips moves + * it out of the boarding pass and into the grid, What's Next has nothing left + * to list, and the forecast for a day in the past is not a forecast. Only the + * browser's `Date` is pinned, so timers keep running and nothing that waits + * stops waiting; the server keeps its own clock, which none of the pictures + * read. + */ +const PICTURE_DAY = new Date('2026-09-21T09:00:00.000Z') + +/** Put the page on the day the pictures are taken on. Call before navigating. */ +async function pinClock(page: Page): Promise { + await page.clock.setFixedTime(PICTURE_DAY) +} + export interface StepAction { /** Bring the screen to where the step starts (runs after the previous step's `act`). */ prepare?: (page: Page) => Promise @@ -86,6 +104,7 @@ export async function typeInto(page: Page, locator: Locator, text: string): Prom /** Run a guide and take its pictures. Throws (fails the test) when a step cannot be performed. */ export async function captureGuide(page: Page, script: GuideScript): Promise { mode = 'still' + await pinClock(page) const { guide } = script if (script.steps.length !== guide.steps) { throw new Error(`guide "${guide.id}" registers ${guide.steps} steps but the script has ${script.steps.length}`) @@ -153,6 +172,7 @@ export async function captureGuide(page: Page, script: GuideScript): Promise Promise): Promise { mode = 'still' + await pinClock(page) const dir = path.join(OUT_DIR, 'ctx') mkdirSync(dir, { recursive: true }) await start(page) diff --git a/client/e2e/help/trip-bookings.guide.ts b/client/e2e/help/trip-bookings.guide.ts new file mode 100644 index 000000000..5c82bf0c9 --- /dev/null +++ b/client/e2e/help/trip-bookings.guide.ts @@ -0,0 +1,651 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures' +import { openTrip, modal, portalDialog } from './trip-shared' +import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the Bookings tab of a trip, keyed by the ids in + * `src/help/contexts/tripBookings.ts`. They run on the seeded "Autumn in Japan" + * with `ensureBookingsFixtures` in front of them, because the seed's only + * reservation is a flight and a flight is a transport: without the fixture this + * tab is its empty state. What a guide creates or changes it puts back in + * `cleanup`, mostly by deleting the booking and letting the fixture build it + * again, so the next one starts from the same seven cards. + */ + +const guide = (id: string): HelpGuide => { + const g = tripBookingsGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** The tab id in the address is the legacy German one; its label is Bookings. */ +const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' }) + +/** + * A booking's card in the list, by its title. + * + * `.last()` because a card holds cards of its own (a traveller pill carries the + * same class), and the filter keeps only the one that names the booking. The + * delete question's card names it too, in its body text, and is portalled to the + * end of the body, so this must not be used while that question stands. + */ +const bookingCard = (page: Page, title: string) => page.locator('.bg-surface-card').filter({ hasText: title }).last() + +/** A field of the booking form, by its own label; the block around it is the label's parent. */ +const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first() +const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..') +/** The two-column or three-column row a field shares with its neighbours. */ +const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..') + +/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */ +const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last() + +const titleBox = (page: Page) => modal(page).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...') +const codeBox = (page: Page) => modal(page).getByPlaceholder('e.g. ABC12345') +const timeBox = (page: Page) => modal(page).getByPlaceholder('00:00') +const saveButton = (page: Page, name: 'Add' | 'Update') => modal(page).getByRole('button', { name, exact: true }) + +/** Type chips and section headers both carry their count inside the accessible name. */ +const counted = (page: Page, name: string) => page.getByRole('button', { name: new RegExp(`^${name} \\d+$`) }) +/** The toolbar's traveller filter: a labelled row of round avatar buttons. */ +const travellers = (page: Page) => page.locator('[aria-label="Travelers"]') + +/** The card's delete question is its own portal, with no backdrop class to find it by. */ +const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true })) +/** BookingImportModal is another bare portal; the heading is what tells it apart. */ +const importDialog = (page: Page) => portalDialog(page, page.getByText('Import booking confirmations', { exact: true })) +/** The background widget's card for the running parse. */ +const importTask = (page: Page) => page.locator('.bg-surface-card').filter({ hasText: 'gracery-confirmation.eml' }).last() + +/** Open the CustomSelect that belongs to a label and take one option out of it. */ +async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: string): Promise { + await block(page, field).getByRole('button').first().click() + await expect(selectMenu(page)).toBeVisible() + if (search) await selectMenu(page).locator('input').fill(search) + await beat(page, 300) + await selectMenu(page).getByRole('button', { name: option }).first().click() + await expect(selectMenu(page)).toHaveCount(0) +} + +/** + * Set a date without touching the calendar: every date field has a keyboard + * button beside it that swaps in a DD.MM.YYYY box, which is one fill instead of + * a month of grid cells that move with the trip's dates. + */ +async function setDate(page: Page, nth: number, value: string): Promise { + await modal(page).getByRole('button', { name: 'Enter date manually' }).nth(nth).click() + const box = modal(page).getByPlaceholder('DD.MM.YYYY') + await box.fill(value) + await box.press('Enter') + await expect(box).toHaveCount(0) +} + +interface Booking { + id: number + title: string +} + +async function bookingsOf(page: Page): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/reservations`) + const body = (await res.json()) as { reservations?: Booking[] } | Booking[] + return Array.isArray(body) ? body : (body.reservations ?? []) +} + +/** + * Delete bookings by title. The route takes the stay of an accommodation and + * the expense linked to a booking with it, so this undoes those too. + */ +async function deleteBookings(page: Page, ...titles: string[]): Promise { + const { tripId } = seededTrip() + for (const booking of (await bookingsOf(page)).filter(b => titles.includes(b.title))) { + await page.request.delete(`/api/trips/${tripId}/reservations/${booking.id}`) + } +} + +/** Put a changed booking back: drop it, then let the fixture build it again. */ +async function restore(page: Page, ...titles: string[]): Promise { + await deleteBookings(page, ...titles) + await ensureBookingsFixtures(page.request) +} + +/** The filters live for the browser session, the two sections for the trip. */ +async function clearPanelState(page: Page): Promise { + const { tripId } = seededTrip() + await page.evaluate(id => { + try { + sessionStorage.removeItem(`trek-reservation-filters-${id}`) + sessionStorage.removeItem(`trek-reservation-filters-${id}-travelers`) + localStorage.removeItem(`trek:bookings-pending-open:${id}`) + localStorage.removeItem(`trek:bookings-confirmed-open:${id}`) + localStorage.removeItem('trek.bg-import-tasks') + } catch { + /* a browser that refuses storage has nothing to clear */ + } + }, tripId) +} + +/** + * Import from file is rendered only when the server can read a confirmation, + * which here means the AI Parsing addon: no extractor binary is installed. + */ +async function setAiParsing(page: Page, enabled: boolean): Promise { + const res = await page.request.put('/api/admin/addons/llm_parsing', { data: { enabled } }) + if (!res.ok()) throw new Error(`could not switch AI Parsing ${enabled ? 'on' : 'off'}: ${res.status()} ${await res.text()}`) +} + +const closeModal = async (page: Page): Promise => { + if (await modal(page).isVisible().catch(() => false)) { + await page.keyboard.press('Escape') + await expect(modal(page)).toHaveCount(0) + } +} + +const only = (target: (p: Page) => Locator) => ({ target }) + +const SCRIPTS: Record = { + 'create-booking': { + guide: guide('create-booking'), + start: openBookings, + steps: [ + { + target: p => p.getByRole('button', { name: 'Manual Booking' }), + act: async p => { + await p.getByRole('button', { name: 'Manual Booking' }).click() + await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => block(p, /^Booking Type$/), + act: async p => { + const pill = modal(p).getByRole('button', { name: 'Event', exact: true }) + await pill.click() + // The pills carry no aria-pressed; the picked one is the inverted chip. + await expect(pill).toHaveClass(/bg-\[var\(--text-primary\)\]/) + await settle(p) + }, + }, + { + prepare: async p => { await typeInto(p, titleBox(p), 'Kabuki at the Minamiza') }, + target: p => block(p, /^Title \*$/), + act: async p => { + await expect(saveButton(p, 'Add')).toBeEnabled() + await settle(p) + }, + }, + { + prepare: async p => { + await setDate(p, 0, '19.09.2026') + await timeBox(p).nth(0).fill('16:30') + await setDate(p, 1, '19.09.2026') + await timeBox(p).nth(1).fill('20:00') + await settle(p) + }, + target: p => row(p, /^Date$/), + act: async p => { + await expect(saveButton(p, 'Add')).toBeEnabled() + await settle(p) + }, + }, + { + prepare: async p => { + await typeInto(p, codeBox(p), 'MZ-5521') + await chooseIn(p, /^Status$/, /^Confirmed$/) + }, + target: p => row(p, /^Booking Code$/), + act: settle, + }, + { + target: p => saveButton(p, 'Add'), + act: async p => { + await saveButton(p, 'Add').click() + await expect(modal(p)).toHaveCount(0) + await expect(bookingCard(p, 'Kabuki at the Minamiza')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteBookings(p, 'Kabuki at the Minamiza'), + }, + 'booking-hotel': { + guide: guide('booking-hotel'), + start: openBookings, + steps: [ + { + prepare: async p => { + await p.getByRole('button', { name: 'Manual Booking' }).click() + await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible() + await settle(p) + }, + target: p => modal(p).getByRole('button', { name: 'Accommodation', exact: true }), + act: async p => { + await modal(p).getByRole('button', { name: 'Accommodation', exact: true }).click() + await expect(label(p, /^Check-in until$/)).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { await chooseIn(p, /^Accommodation$/, /^Hotel Granvia Kyoto$/, 'Granvia') }, + target: p => block(p, /^Accommodation$/), + act: async p => { + await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name) + await settle(p) + }, + }, + { + prepare: async p => { + await chooseIn(p, /^From$/, /^Day 5 /) + await chooseIn(p, /^To$/, /^Day 8 /) + }, + target: p => row(p, /^To$/), + act: settle, + }, + { + prepare: async p => { + await timeBox(p).nth(0).fill('15:00') + await timeBox(p).nth(1).fill('23:00') + await timeBox(p).nth(2).fill('11:00') + await typeInto(p, codeBox(p), 'GRK-40218') + }, + target: p => row(p, /^Check-in$/), + act: settle, + }, + { + target: p => saveButton(p, 'Add'), + act: async p => { + await saveButton(p, 'Add').click() + await expect(modal(p)).toHaveCount(0) + await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteBookings(p, HOTEL_TO_BOOK.name), + }, + 'link-booking': { + guide: guide('link-booking'), + start: openBookings, + steps: [ + { + target: p => bookingCard(p, 'teamLab Planets timed entry').getByRole('button', { name: 'Edit' }), + act: async p => { + await bookingCard(p, 'teamLab Planets timed entry').getByRole('button', { name: 'Edit' }).click() + await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await block(p, /^Link to day assignment$/).getByRole('button').first().click() + await expect(selectMenu(p)).toBeVisible() + await beat(p, 300) + }, + target: selectMenu, + act: async p => { + await selectMenu(p).getByRole('button', { name: /^2\. teamLab Planets/ }).click() + await expect(selectMenu(p)).toHaveCount(0) + await expect(block(p, /^Link to day assignment$/).getByRole('button', { name: /teamLab Planets/ })).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { await chooseIn(p, /^Place \/ Activity$/, /^teamLab Planets$/, 'teamLab') }, + target: p => block(p, /^Place \/ Activity$/), + act: settle, + }, + { + target: p => saveButton(p, 'Update'), + act: async p => { + await saveButton(p, 'Update').click() + await expect(modal(p)).toHaveCount(0) + await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Link to day assignment')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => restore(p, 'teamLab Planets timed entry'), + }, + 'booking-travelers': { + guide: guide('booking-travelers'), + start: openBookings, + steps: [ + { + prepare: async p => { + await bookingCard(p, 'Fushimi Inari night walk').getByRole('button', { name: 'Edit' }).click() + await expect(label(p, /^Travelers$/)).toBeVisible() + await settle(p) + }, + target: p => block(p, /^Travelers$/), + act: settle, + }, + { + target: p => block(p, /^Travelers$/).getByRole('button', { name: /jonas/ }), + act: async p => { + const pill = block(p, /^Travelers$/).getByRole('button', { name: /jonas/ }) + await pill.click() + await expect(pill).toHaveAttribute('aria-pressed', 'true') + await settle(p) + }, + }, + { + target: p => saveButton(p, 'Update'), + act: async p => { + await saveButton(p, 'Update').click() + await expect(modal(p)).toHaveCount(0) + await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => travellers(p).getByRole('button', { name: 'jonas' }), + act: async p => { + await travellers(p).getByRole('button', { name: 'jonas' }).click() + await expect(travellers(p).getByRole('button', { name: 'jonas' })).toHaveAttribute('aria-pressed', 'true') + await settle(p) + }, + }, + ], + cleanup: async p => { + await clearPanelState(p) + await restore(p, 'Fushimi Inari night walk') + }, + }, + 'booking-files': { + guide: guide('booking-files'), + start: openBookings, + steps: [ + { + prepare: async p => { + await bookingCard(p, 'teamLab Planets timed entry').getByRole('button', { name: 'Edit' }).click() + await expect(modal(p).getByRole('button', { name: 'Attach file' })).toBeVisible() + await settle(p) + }, + target: p => modal(p).getByRole('button', { name: 'Attach file' }), + act: async p => { + // Set the hidden input directly: clicking the button opens the file + // picker of the operating system, which Playwright cannot photograph. + await modal(p).locator('input[type="file"]').setInputFiles(bookingPdfFixture('teamlab-tickets')) + await expect(modal(p).getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + only(p => modal(p).getByText('teamlab-tickets.pdf').locator('xpath=..')), + { + prepare: async p => { + await modal(p).getByRole('button', { name: 'Link existing file' }).click() + await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toBeVisible() + await beat(p, 300) + }, + target: p => modal(p).getByRole('button', { name: 'Link existing file' }).locator('xpath=..'), + act: async p => { + await modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true }).click() + await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toHaveCount(0) + await expect(modal(p).getByText(UNATTACHED_FILE)).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + }, + { + target: p => saveButton(p, 'Update'), + act: async p => { + await saveButton(p, 'Update').click() + await expect(modal(p)).toHaveCount(0) + await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + // Deleting the booking takes the link row to the voucher with it, so the + // voucher is unattached again and Link existing file offers it next time. + cleanup: async p => { + const { tripId } = seededTrip() + const res = await p.request.get(`/api/trips/${tripId}/files`) + const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] } + for (const file of files.filter(f => f.original_name === 'teamlab-tickets.pdf')) { + await p.request.delete(`/api/trips/${tripId}/files/${file.id}`) + } + await restore(p, 'teamLab Planets timed entry') + }, + }, + 'booking-cost': { + guide: guide('booking-cost'), + start: openBookings, + steps: [ + { + prepare: async p => { + await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click() + await expect(modal(p).getByRole('button', { name: 'Create expense' })).toBeVisible() + await settle(p) + }, + target: p => block(p, /^Costs$/), + act: settle, + }, + { + target: p => modal(p).getByRole('button', { name: 'Create expense' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Create expense' }).click() + await expect(p.getByRole('heading', { name: 'Add expense' })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await modal(p).getByPlaceholder('0.00').fill('12000') + await settle(p) + }, + target: p => block(p, /^Total amount$/), + act: settle, + }, + only(p => block(p, /^Who paid\?$/).locator('xpath=..')), + { + target: p => modal(p).getByRole('button', { name: 'Add expense', exact: true }), + act: async p => { + const save = modal(p).getByRole('button', { name: 'Add expense', exact: true }) + // What was it for? comes prefilled from the booking; without it the button is dead. + await expect(save).toBeEnabled() + await save.click() + await expect(modal(p)).toHaveCount(0) + await settle(p) + // The block only reads Linked expense once the booking is opened again. + await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click() + await expect(label(p, /^Linked expense$/)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + await closeModal(p) + await restore(p, 'Kyoto Cycling Tour') + }, + }, + 'filter-bookings': { + guide: guide('filter-bookings'), + start: openBookings, + steps: [ + only(p => counted(p, 'All').locator('xpath=..')), + { + prepare: async p => { + await counted(p, 'Tour').click() + await settle(p) + }, + target: p => counted(p, 'Tour'), + act: async p => { + await counted(p, 'Event').click() + await expect(bookingCard(p, 'Lunch at Nishiki')).toHaveCount(0) + await settle(p) + }, + }, + { + target: p => counted(p, 'All'), + act: async p => { + await counted(p, 'All').click() + await expect(bookingCard(p, 'Lunch at Nishiki')).toBeVisible() + await settle(p) + }, + }, + // The gesture belongs to booking-travelers; here the row is only pointed at. + only(travellers), + { + target: p => counted(p, 'Pending'), + act: async p => { + await counted(p, 'Pending').click() + await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0) + await counted(p, 'Pending').click() + await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible() + await settle(p) + }, + }, + ], + cleanup: clearPanelState, + }, + 'import-booking-file': { + guide: guide('import-booking-file'), + start: async p => { + await setAiParsing(p, true) + await openBookings(p) + }, + steps: [ + { + target: p => p.getByRole('button', { name: 'Import from file' }), + act: async p => { + await p.getByRole('button', { name: 'Import from file' }).click() + await expect(importDialog(p)).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await importDialog(p).locator('input[type="file"]').setInputFiles(bookingEmlFixture()) + await expect(importDialog(p).getByText('gracery-confirmation.eml')).toBeVisible() + await settle(p) + }, + target: p => importDialog(p).getByText('gracery-confirmation.eml').locator('xpath=ancestor::button[1]'), + act: settle, + }, + { + target: p => importDialog(p).getByRole('button', { name: 'Import', exact: true }), + act: async p => { + await importDialog(p).getByRole('button', { name: 'Import', exact: true }).click() + await expect(importDialog(p)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await expect(importTask(p)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + target: importTask, + act: settle, + }, + ], + cleanup: async p => { + // The widget only offers its Close once the run has stopped, and a card + // left standing would sit in the corner of every later guide's pictures. + // The wait is bounded well inside the 150 s a help-media test gets + // (playwright.config.ts): the four steps take about 40 s of it, so a + // longer one would fail the whole guide over a card nobody can close. + const close = importTask(p).getByRole('button', { name: 'Close' }) + await close.waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {}) + if (await close.isVisible().catch(() => false)) await close.click() + await setAiParsing(p, false) + await clearPanelState(p) + // A parse that did produce something would have left its bookings behind. + await deleteBookings(p, 'Hotel Granvia Kyoto') + }, + }, + 'edit-booking': { + guide: guide('edit-booking'), + start: openBookings, + steps: [ + { + target: p => bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }), + act: async p => { + await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click() + await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { await typeInto(p, codeBox(p), 'KCT-90412') }, + target: p => block(p, /^Booking Code$/), + act: settle, + }, + { + prepare: async p => { + await block(p, /^Status$/).getByRole('button').first().click() + await expect(selectMenu(p)).toBeVisible() + await beat(p, 300) + }, + target: p => block(p, /^Status$/), + act: async p => { + await selectMenu(p).getByRole('button', { name: /^Confirmed$/ }).click() + await expect(selectMenu(p)).toHaveCount(0) + await expect(block(p, /^Status$/).getByRole('button', { name: 'Confirmed' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => saveButton(p, 'Update'), + act: async p => { + await saveButton(p, 'Update').click() + await expect(modal(p)).toHaveCount(0) + await expect(bookingCard(p, 'Kyoto Cycling Tour').getByText('Confirmed', { exact: true }).first()).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => restore(p, 'Kyoto Cycling Tour'), + }, + 'delete-booking': { + guide: guide('delete-booking'), + start: openBookings, + steps: [ + { + target: p => bookingCard(p, 'Haneda Airport P4').getByRole('button', { name: 'Delete' }), + act: async p => { + await bookingCard(p, 'Haneda Airport P4').getByRole('button', { name: 'Delete' }).click() + await expect(deleteAsk(p)).toBeVisible() + await settle(p) + }, + }, + only(deleteAsk), + { + target: p => deleteAsk(p).getByRole('button', { name: 'Confirm' }), + act: async p => { + await deleteAsk(p).getByRole('button', { name: 'Confirm' }).click() + await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => ensureBookingsFixtures(p.request), + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureBookingsFixtures(request) +}) + +test('every registered bookings guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripBookingsContext.guides].sort()) +}) + +test('hero: trip-bookings', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripBookingsContext.id, openBookings) +}) + +for (const id of tripBookingsContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-collab.guide.ts b/client/e2e/help/trip-collab.guide.ts new file mode 100644 index 000000000..61efa1776 --- /dev/null +++ b/client/e2e/help/trip-collab.guide.ts @@ -0,0 +1,475 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures' +import { openTrip } from './trip-shared' +import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the Collab tab of a trip, keyed by the ids in + * `src/help/contexts/tripCollab.ts`. They run on the seeded "Autumn in Japan", + * whose collab addon already holds three notes, two polls and a seven message + * conversation; `ensureCollabFixtures` adds the shared links, a throwaway poll + * for the one guide that closes a poll for good, and the two upcoming stops + * What's Next needs. What a guide writes it takes away again in `cleanup`, with + * one deliberate exception: a deleted chat message cannot be undeleted, which is + * why `trip-chat` is the last guide of the screen. + */ + +const guide = (id: string): HelpGuide => { + const g = tripCollabGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +const NOTE = { + title: 'Luggage forwarding', + body: 'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag', + category: 'Transport', + website: 'https://www.global-yamato.com/en/hc/', +} +/** An .example host never resolves, so the chip shows its own glyph offline too. */ +const LINK = { title: 'Hakone ryokan booking', url: 'https://www.hakone-ryokan.example/booking' } +const POLL = { + // Plain text on purpose: a bold run in the question would wrap the paragraph + // in one more element and move the card away from `pollCard`'s ancestor depth. + question: 'Which evening do we keep for the kaiseki dinner?', + options: ['Wed 16th, after Fushimi Inari', 'Thu 17th, after Arashiyama', 'Either, decide on the day'], +} +/** The seeded poll nobody has voted on yet. */ +const SEEDED_POLL = 'Ryokan or city hotel in Hakone?' +const CHAT = { + mine: 'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.', + /** Typed before the picker opens, so the picture shows the emoji being added to it. */ + emoji: 'Two weeks to go ', + flag: '🎌', + theirs: 'Drop bags. I want to be at Senso-ji before the crowds.', + answer: 'Agreed, bags at the hotel first, then Asakusa.', + reactTo: 'Booked the teamLab slot for the 13th, 14:00.', + reaction: '👍', +} + +// ── Locators ───────────────────────────────────────────────────────────────── + +/** Each of the four right-hand panels is a card; find it from what only it holds. */ +const CARD = 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"bg-surface-card")][1]' +const notesPanel = (page: Page) => page.getByRole('button', { name: 'New Note' }).locator(CARD) +const pollsPanel = (page: Page) => page.getByRole('button', { name: 'New Poll' }).locator(CARD) +const linksPanel = (page: Page) => page.getByRole('button', { name: 'Add link' }).locator(CARD) +const nextPanel = (page: Page) => page.getByText("What's Next", { exact: true }).locator(CARD) + +const chatBox = (page: Page) => page.getByPlaceholder('Type a message...') +/** The row that holds the emoji button, the image button, the box and the arrow. */ +const composer = (page: Page) => chatBox(page).locator('xpath=..') +/** + * The quoted message above the box while a reply is being written. With no reply + * pending the same position is the composer row itself, which is what the step + * after the send asserts against. + */ +const replyQuote = (page: Page) => chatBox(page).locator('xpath=../..').locator('> div').first() +/** The send button and the emoji button carry no text and no label at all. */ +const sendButton = (page: Page) => page.locator('button:has(svg.lucide-arrow-up)') +const emojiButton = (page: Page) => page.locator('button:has(svg.lucide-smile)') +/** The emoji picker is a portal; its three category tabs are the sure thing in it. */ +const emojiPicker = (page: Page) => page.getByRole('button', { name: 'Travel', exact: true }).locator('xpath=../..') +/** The right-click menu of eight reactions: a 4x2 grid in a portal of its own. */ +const reactionMenu = (page: Page) => page.locator('div[style*="repeat(4"]').last() +/** One message, by the relative wrapper that also carries its Reply and Delete. */ +const bubble = (page: Page, text: string) => + page.getByText(text).first().locator('xpath=ancestor::div[contains(@style,"position: relative")][1]') +/** + * Put the pointer on a message's Reply or Delete. The row holding them is + * `pointer-events: none` until its own bubble is hovered, and it sits at + * `top: -14px`, outside that bubble's box: hovering the button alone would land + * on whatever is behind it. `captureGuide` scrolls the target to the middle of + * the frame between `prepare` and the shot, and that scroll can take the bubble + * out from under the pointer, so the hover is made again here, bubble first. + */ +const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (page: Page): Promise => { + await page.getByText(text).first().hover() + await beat(page, 300) + await bubble(page, text).getByRole('button', { name }).hover() +} + +/** The note form is a `form[role="presentation"]` in its own portal, not a Modal. */ +const noteForm = (page: Page) => page.locator('form[role="presentation"]') +/** The poll and link forms sit inside a full-screen overlay; ring the form, not it. */ +const pollForm = (page: Page) => page.locator('form').filter({ has: page.getByPlaceholder('What should we do?') }) +const linkForm = (page: Page) => page.locator('form').filter({ has: page.locator('#collab-link-url') }) + +const noteCard = (page: Page, title: string) => + notesPanel(page).getByText(title, { exact: true }).locator('xpath=ancestor::div[2]') +/** A poll card, from its question: the question is markdown, so it is four up. */ +const pollCard = (page: Page, question: string) => + page.getByText(question, { exact: true }).locator('xpath=ancestor::div[4]') +const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title }) +/** A What's Next row, from the place name in it: name → details → row. */ +const nextRow = (page: Page, place: string) => + nextPanel(page).getByText(place, { exact: true }).locator('xpath=../..') + +// ── Start and cleanup ──────────────────────────────────────────────────────── + +const start = async (page: Page): Promise => { + await openTrip(page, { tab: 'collab' }) + // The Collab panel is a lazy chunk: everything below needs it mounted. + await expect(page.getByRole('button', { name: 'New Poll' })).toBeVisible({ timeout: 30_000 }) + await settle(page) +} + +const collab = (): string => `/api/trips/${seededTrip().tripId}/collab` + +async function listOf(page: Page, path: string, key: string): Promise { + const res = await page.request.get(`${collab()}/${path}`) + const body = (await res.json()) as Record + return body[key] ?? [] +} + +async function deleteNote(page: Page, title: string): Promise { + const notes = await listOf<{ id: number; title: string }>(page, 'notes', 'notes') + for (const note of notes.filter(n => n.title === title)) { + await page.request.delete(`${collab()}/notes/${note.id}`) + } +} + +async function deleteLink(page: Page, title: string): Promise { + const links = await listOf<{ id: number; title: string }>(page, 'links', 'links') + for (const link of links.filter(l => l.title === title)) { + await page.request.delete(`${collab()}/links/${link.id}`) + } +} + +async function deletePoll(page: Page, question: string): Promise { + const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls') + for (const poll of polls.filter(p => p.question === question)) { + await page.request.delete(`${collab()}/polls/${poll.id}`) + } +} + +/** + * Voting for an option you already chose deletes the row, so one call puts the + * seeded, unvoted poll back the way the next guide expects it. + */ +async function resetVote(page: Page, question: string, optionIndex: number): Promise { + const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls') + const poll = polls.find(p => p.question === question) + if (poll) await page.request.post(`${collab()}/polls/${poll.id}/vote`, { data: { option_index: optionIndex } }) +} + +const only = (target: (page: Page) => Locator) => ({ target }) + +// ── Scripts ────────────────────────────────────────────────────────────────── + +const SCRIPTS: Record = { + 'write-note': { + guide: guide('write-note'), + start, + steps: [ + { + target: page => notesPanel(page).getByRole('button', { name: 'New Note' }), + act: async page => { + await notesPanel(page).getByRole('button', { name: 'New Note' }).click() + await expect(noteForm(page)).toBeVisible() + await settle(page) + }, + }, + { + prepare: page => typeInto(page, noteForm(page).getByPlaceholder('Note title'), NOTE.title), + target: page => noteForm(page).getByPlaceholder('Note title'), + }, + { + prepare: page => typeInto(page, noteForm(page).getByPlaceholder('Write something...'), NOTE.body), + target: page => noteForm(page).getByPlaceholder('Write something...'), + }, + { + // The pills are the categories that already exist; ring the row, then pick. + target: page => noteForm(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=..'), + act: async page => { + await noteForm(page).getByRole('button', { name: NOTE.category, exact: true }).click() + await beat(page, 300) + }, + }, + { + prepare: page => typeInto(page, noteForm(page).getByPlaceholder('https://...'), NOTE.website), + target: page => noteForm(page).getByPlaceholder('https://...'), + }, + { + target: page => noteForm(page).getByRole('button', { name: 'Create', exact: true }), + act: async page => { + await noteForm(page).getByRole('button', { name: 'Create', exact: true }).click() + await expect(noteForm(page)).toHaveCount(0) + await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + ], + cleanup: page => deleteNote(page, NOTE.title), + }, + 'shared-links': { + guide: guide('shared-links'), + start, + steps: [ + { + target: page => linksPanel(page).getByRole('button', { name: 'Add link' }), + act: async page => { + await linksPanel(page).getByRole('button', { name: 'Add link' }).click() + await expect(linkForm(page)).toBeVisible() + await settle(page) + }, + }, + { + prepare: async page => { + await typeInto(page, linkForm(page).locator('#collab-link-title'), LINK.title) + await typeInto(page, linkForm(page).locator('#collab-link-url'), LINK.url) + await beat(page, 300) + }, + target: linkForm, + act: async page => { + await linkForm(page).getByRole('button', { name: 'Save link' }).click() + await expect(linkForm(page)).toHaveCount(0) + await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + { + prepare: async page => { await linkChip(page, LINK.title).hover() }, + target: page => linkChip(page, LINK.title).locator('.collab-link-chip__main'), + }, + { + target: page => linkChip(page, LINK.title).locator('.collab-link-chip__actions'), + act: async page => { + // Exact: an accessible name is matched as a substring, and "Unpin link" + // holds "Pin link", so the loose form would find both buttons. + await linkChip(page, LINK.title).getByRole('button', { name: 'Pin link', exact: true }).click() + await expect(linkChip(page, LINK.title)).toHaveClass(/collab-link-chip--pinned/) + await expect(page.locator('.collab-link-grid__cells > div').first()).toContainText(LINK.title) + await settle(page) + }, + }, + ], + cleanup: page => deleteLink(page, LINK.title), + }, + 'create-poll': { + guide: guide('create-poll'), + start, + steps: [ + { + target: page => pollsPanel(page).getByRole('button', { name: 'New Poll' }), + act: async page => { + await pollsPanel(page).getByRole('button', { name: 'New Poll' }).click() + await expect(pollForm(page)).toBeVisible() + await settle(page) + }, + }, + { + prepare: page => typeInto(page, pollForm(page).getByPlaceholder('What should we do?'), POLL.question), + // The label, the box and the Markdown supported hint under it. + target: page => pollForm(page).getByPlaceholder('What should we do?').locator('xpath=..'), + }, + { + prepare: async page => { + await typeInto(page, pollForm(page).getByPlaceholder('Option 1'), POLL.options[0]) + await typeInto(page, pollForm(page).getByPlaceholder('Option 2'), POLL.options[1]) + }, + target: page => pollForm(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[2]'), + }, + { + target: page => pollForm(page).getByRole('button', { name: '+ Add option' }), + act: async page => { + await pollForm(page).getByRole('button', { name: '+ Add option' }).click() + await typeInto(page, pollForm(page).getByPlaceholder('Option 3'), POLL.options[2]) + await beat(page, 300) + }, + }, + // The switch alone is 36 px wide; ring the label it sits in. + only(page => pollForm(page).getByRole('switch', { name: 'Multiple choice' }).locator('xpath=..')), + { + target: page => pollForm(page).getByRole('button', { name: 'Create Poll' }), + act: async page => { + await pollForm(page).getByRole('button', { name: 'Create Poll' }).click() + await expect(pollForm(page)).toHaveCount(0) + await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + ], + cleanup: page => deletePoll(page, POLL.question), + }, + 'vote-poll': { + guide: guide('vote-poll'), + start, + steps: [ + { + target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }), + act: async page => { + await pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }).click() + await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*100%/ })).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + // Both bars and both percentages, so the step about reading them has them. + only(page => pollCard(page, SEEDED_POLL).locator('> div').last()), + { + target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }), + act: async page => { + await pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }).click() + await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /Ryokan with onsen.*100%/ })).toBeVisible({ timeout: 15_000 }) + await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*0%/ })).toBeVisible() + await settle(page) + }, + }, + // The counter is hardcoded English in the component: "1 vote" / "3 votes". + only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)), + ], + cleanup: page => resetVote(page, SEEDED_POLL, 0), + }, + 'close-poll': { + guide: guide('close-poll'), + start, + steps: [ + { + target: page => pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-lock)'), + act: async page => { + await pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-lock)').click() + await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + only(page => pollCard(page, COLLAB_SPARE_POLL.question)), + { + target: page => pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-trash2)'), + act: async page => { + await pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-trash2)').click() + await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 }) + await settle(page) + }, + }, + ], + // The last step normally already removed it; this is for a run that stopped short. + cleanup: page => deletePoll(page, COLLAB_SPARE_POLL.question), + }, + 'whats-next': { + guide: guide('whats-next'), + start, + steps: [ + only(nextPanel), + // The time column is the first child of a row, the chips the last of its details. + only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()), + only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last().locator('> div').last()), + ], + // Read-only panel: the guide writes nothing. + }, + 'trip-chat': { + guide: guide('trip-chat'), + start, + steps: [ + { + prepare: page => typeInto(page, chatBox(page), CHAT.mine), + target: composer, + act: async page => { + await chatBox(page).press('Enter') + await expect(page.getByText(CHAT.mine)).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + { + prepare: async page => { + await typeInto(page, chatBox(page), CHAT.emoji) + await emojiButton(page).click() + await expect(emojiPicker(page)).toBeVisible() + await beat(page, 300) + }, + target: emojiPicker, + act: async page => { + await emojiPicker(page).getByRole('button', { name: 'Travel', exact: true }).click() + await emojiPicker(page).getByRole('button', { name: CHAT.flag }).click() + await expect(chatBox(page)).toHaveValue(`${CHAT.emoji}${CHAT.flag}`) + await sendButton(page).click() + await expect(page.getByText(`${CHAT.emoji}${CHAT.flag}`)).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + { + // The hover row is pointer-events:none until the bubble under it is hovered, + // so the bubble has to be hovered before the button can be reached at all. + prepare: async page => { + await page.getByText(CHAT.theirs).first().hover() + await beat(page, 300) + }, + target: page => bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }), + hover: hoverMessageAction(CHAT.theirs, 'Reply'), + act: async page => { + await bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }).click() + await expect(replyQuote(page)).toContainText(CHAT.theirs) + await settle(page) + }, + }, + { + prepare: page => typeInto(page, chatBox(page), CHAT.answer), + target: replyQuote, + act: async page => { + await chatBox(page).press('Enter') + await expect(page.getByText(CHAT.answer)).toBeVisible({ timeout: 15_000 }) + await expect(replyQuote(page)).not.toContainText(CHAT.theirs) + await settle(page) + }, + }, + { + prepare: async page => { + await page.getByText(CHAT.reactTo).first().click({ button: 'right' }) + await expect(reactionMenu(page)).toBeVisible() + await beat(page, 300) + }, + target: reactionMenu, + act: async page => { + await reactionMenu(page).getByRole('button', { name: CHAT.reaction }).click() + await expect( + bubble(page, CHAT.reactTo).locator('xpath=..').getByRole('button', { name: CHAT.reaction }), + ).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + { + prepare: async page => { + await page.getByText(CHAT.mine).first().hover() + await beat(page, 300) + }, + target: page => bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }), + hover: hoverMessageAction(CHAT.mine, 'Delete'), + act: async page => { + await bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }).click() + // The bubble shrinks for 400 ms before the request goes out. + await page.waitForTimeout(1200) + await expect(page.getByText(/deleted a message/)).toBeVisible({ timeout: 15_000 }) + await settle(page) + }, + }, + ], + // No cleanup, deliberately: deleting a message is a soft delete and there is + // no undelete over the API, so undoing anything here would only add more + // tombstones. This is why trip-chat is the last guide of the screen. + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureCollabFixtures(request) +}) + +test('every registered collab guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCollabContext.guides].sort()) +}) + +test('hero: trip-collab', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripCollabContext.id, start) +}) + +for (const id of tripCollabContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-costs.guide.ts b/client/e2e/help/trip-costs.guide.ts new file mode 100644 index 000000000..23fc08c7d --- /dev/null +++ b/client/e2e/help/trip-costs.guide.ts @@ -0,0 +1,568 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureCostsFixtures } from './fixtures' +import { openTrip, modal, dialog } from './trip-shared' +import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the Costs tab of a trip, keyed by the ids in + * `src/help/contexts/tripCosts.ts`. They run on the seeded "Autumn in Japan", + * which keeps its books in JPY with one expense in EUR and one transfer already + * recorded, so the frozen rate and the settle-up column both have something + * real to show. What a guide writes it takes away again in `cleanup`, the + * settlements included, because the next guide reads the same balances. + */ + +const guide = (id: string): HelpGuide => { + const g = tripCostsGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** The expense nobody has paid for; `ensureCostsFixtures` puts it there once, for every guide. */ +const UNPAID = 'Airport transfer (Narita Express)' +/** The seeded expense in euros, the one carrying a frozen rate. */ +const SEEDED_EUR = 'Flights FRA → HND' +/** The seeded flight booking, on the Transports tab because its type is flight. */ +const BOOKING = 'LH716 FRA → HND' +const DINNER = 'Kaiseki dinner in Gion' +const UPGRADE = 'Shinkansen seat upgrade' +const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: '2026-09-17' } + +/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */ +const openCosts = async (p: Page): Promise => { + await openTrip(p, { tab: 'finanzplan' }) + await expect(p.getByRole('button', { name: 'Add expense' }).first()).toBeVisible({ timeout: 30_000 }) + await expect(p.locator('.exp-row').first()).toBeVisible({ timeout: 30_000 }) + await settle(p) +} + +/** A row of the ledger by the expense's name. */ +const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ hasText: name }).first() +/** + * The pencil/bin pill beside a row. It is a sibling of `.exp-row`, not a child, + * so a locator scoped to the row itself would never find it. + */ +const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions') +/** A recorded transfer in the ledger, told apart by who paid it. */ +const payRow = (page: Page, who: string) => + page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: who }).first() +/** A block of the expense editor, found by its own label. */ +const panel = (page: Page, label: string) => + modal(page).getByText(label, { exact: true }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]') +/** A card of the right-hand column, by the label it opens with. */ +const card = (page: Page, label: RegExp) => + page.locator('.costs-grid > div:nth-child(2) > div').filter({ hasText: label }).first() +/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */ +const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last() + +const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]') +const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…') +/** The header button; the editor's own save carries the same words, so it is scoped to the modal. */ +const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first() +const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' }) +const settleButtons = (page: Page) => card(page, /^Settle up · /).getByRole('button', { name: 'Settle' }) +/** + * One suggested transfer in the Settle up card, by the title its pair of avatars + * carries ("jonas → you"); the row itself is that pair's parent. The card lists + * the flows in whatever order the settlement endpoint returns them, so taking + * the first Settle button would settle whichever transfer happened to come + * first, and the ledger steps below look for a named one. + */ +const settleRow = (page: Page, from: string) => + card(page, /^Settle up · /).locator(`div[title^="${from} "]`).locator('xpath=..') + +const only = (target: (p: Page) => Locator) => ({ target }) + +const closeModal = async (page: Page): Promise => { + if (await modal(page).isVisible().catch(() => false)) { + await page.keyboard.press('Escape') + await expect(modal(page)).toHaveCount(0) + } +} + +async function deleteExpenses(page: Page, ...names: string[]): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/budget`) + const { items } = (await res.json()) as { items: { id: number; name: string }[] } + for (const item of items.filter(i => names.includes(i.name))) { + await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`) + } +} + +async function createExpense( + page: Page, + item: { name: string; category: string; total_price: number; currency: string; expense_date: string }, +): Promise { + const { tripId, memberIds } = seededTrip() + const created = await page.request.post(`/api/trips/${tripId}/budget`, { + data: { ...item, payers: [{ user_id: 1, amount: item.total_price }], member_ids: [1, ...memberIds] }, + }) + if (!created.ok()) throw new Error(`could not create "${item.name}": ${created.status()} ${await created.text()}`) +} + +async function settlementIds(page: Page): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/budget/settlements`) + const { settlements } = (await res.json()) as { settlements: { id: number }[] } + return settlements.map(s => s.id) +} + +/** The transfers that were there before the settle-up guide ran; everything else it made, it deletes. */ +let transfersBefore: number[] = [] + +/** Open the expense editor's currency list, search for a code and take it. */ +async function pickCurrency(page: Page, code: string): Promise { + await modal(page).getByRole('button', { name: /^JPY/ }).first().click() + await beat(page, 300) + await dropdown(page).locator('input').first().fill(code) + await dropdown(page).getByRole('button', { name: new RegExp(`^${code}`) }).click() + await expect(modal(page).getByRole('button', { name: new RegExp(`^${code}`) }).first()).toBeVisible() + await settle(page) +} + +const SCRIPTS: Record = { + 'add-expense': { + guide: guide('add-expense'), + start: openCosts, + steps: [ + { + target: addExpense, + act: async p => { + await addExpense(p).click() + await expect(nameBox(p)).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await typeInto(p, nameBox(p), DINNER) + await amounts(p).first().fill('18600') + await expect(saveExpense(p)).toBeEnabled() + await settle(p) + }, + // The left block carries the name, the amount, the currency and the day. + target: p => panel(p, 'What was it for?'), + }, + // The two-column row inside that block, so the step about them is not the + // same picture as the step about the name. + only(p => modal(p).getByText('Currency', { exact: true }).locator('xpath=../..')), + { + prepare: async p => { + await modal(p).getByRole('button', { name: 'Food & drink' }).click() + // The chosen pill is marked by its border alone, not by a class. + await expect(modal(p).getByRole('button', { name: 'Food & drink' })).toHaveAttribute( + 'style', + /border-color: var\(--text-primary\)/, + ) + await settle(p) + }, + target: p => panel(p, 'Category'), + }, + only(p => panel(p, 'Who paid?')), + { + target: saveExpense, + act: async p => { + await saveExpense(p).click() + await expect(modal(p)).toHaveCount(0) + await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteExpenses(p, DINNER), + }, + + 'expense-payers': { + guide: guide('expense-payers'), + start: openCosts, + steps: [ + { + prepare: async p => { + await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click() + await expect(panel(p, 'Who paid?')).toBeVisible() + await settle(p) + }, + target: p => panel(p, 'Who paid?'), + }, + { + prepare: async p => { + await panel(p, 'Who paid?').getByRole('button').filter({ hasText: 'You' }).first().click() + await beat(p, 300) + await expect(p.getByRole('button', { name: 'No one paid yet' })).toBeVisible() + }, + target: p => p.getByRole('button', { name: 'No one paid yet' }), + // The list closes on a second click of its trigger, not on Escape; nothing is picked. + act: async p => { + await panel(p, 'Who paid?').getByRole('button').filter({ hasText: 'You' }).first().click() + await expect(p.getByRole('button', { name: 'No one paid yet' })).toHaveCount(0) + await settle(p) + }, + }, + { + prepare: async p => { + await modal(p).getByRole('button', { name: 'Multiple people paid' }).click() + await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3) + await modal(p).getByTestId('payer-toggle').nth(1).click() + await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2) + await settle(p) + }, + target: p => panel(p, 'Who paid?'), + act: async p => { + await modal(p).getByRole('button', { name: 'One person paid' }).click() + await closeModal(p) + await settle(p) + }, + }, + { + target: p => expRow(p, UNPAID), + act: async p => { + await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible() + }, + }, + ], + }, + + 'split-expense': { + guide: guide('split-expense'), + start: async p => { + await createExpense(p, LUNCH) + await openCosts(p) + }, + steps: [ + { + prepare: async p => { + await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click() + await expect(panel(p, 'Split')).toBeVisible() + await panel(p, 'Split').getByRole('button', { name: /jonas/ }).click() + await expect(modal(p).getByText('Excluded')).toBeVisible() + await settle(p) + }, + target: p => panel(p, 'Split'), + act: async p => { + await panel(p, 'Split').getByRole('button', { name: /jonas/ }).click() + await expect(modal(p).getByText('Excluded')).toHaveCount(0) + await settle(p) + }, + }, + only(p => modal(p).getByText(/^Split \d+ ways · /)), + { + prepare: async p => { + await modal(p).getByRole('button', { name: 'Custom', exact: true }).click() + await amounts(p).nth(1).fill('1000') + await expect(modal(p).getByText(/^Sum of splits: /)).toBeVisible() + await settle(p) + }, + target: p => modal(p).getByText(/^Sum of splits: /), + act: async p => { + await amounts(p).nth(2).fill('1900') + await amounts(p).nth(3).fill('1900') + await expect(modal(p).getByText('Split matches total')).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await modal(p).getByRole('button', { name: 'Ticket', exact: true }).click() + await modal(p).getByRole('button', { name: 'Add item' }).click() + await typeInto(p, modal(p).getByPlaceholder('Item name').first(), 'Tamagoyaki') + await amounts(p).nth(1).fill('1200') + await modal(p).getByRole('button', { name: 'Add item' }).click() + await typeInto(p, modal(p).getByPlaceholder('Item name').nth(1), 'Soy milk donuts') + await amounts(p).nth(2).fill('900') + // The second line is shared by two of the three travelers. + await modal(p) + .getByPlaceholder('Item name') + .nth(1) + .locator('xpath=ancestor::div[2]') + .getByRole('button', { name: /jonas/ }) + .click() + await expect(amounts(p).first()).toHaveValue('2100.00') + await settle(p) + }, + target: p => modal(p).getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'), + }, + { + target: p => modal(p).getByText('Individual shares').locator('xpath=..'), + act: async p => { + await modal(p).getByRole('button', { name: 'Save', exact: true }).click() + await expect(modal(p)).toHaveCount(0) + await settle(p) + }, + }, + ], + cleanup: p => deleteExpenses(p, LUNCH.name), + }, + + 'expense-currency': { + guide: guide('expense-currency'), + start: openCosts, + steps: [ + { + prepare: async p => { + await addExpense(p).click() + await expect(nameBox(p)).toBeVisible() + await typeInto(p, nameBox(p), UPGRADE) + await amounts(p).first().fill('48') + // The editor opens on Food & drink; a seat upgrade filed under it would + // read wrong on the ledger row this guide's last picture is of. + await modal(p).getByRole('button', { name: 'Transport', exact: true }).click() + await settle(p) + }, + target: p => panel(p, 'What was it for?'), + }, + { + prepare: async p => { + await modal(p).getByRole('button', { name: /^JPY/ }).first().click() + await beat(p, 300) + await dropdown(p).locator('input').first().fill('EUR') + await expect(dropdown(p).getByRole('button', { name: /^EUR/ })).toBeVisible() + }, + target: p => modal(p).getByRole('button', { name: /^JPY/ }).first(), + act: async p => { + await dropdown(p).getByRole('button', { name: /^EUR/ }).click() + await expect(modal(p).getByRole('button', { name: /^EUR/ }).first()).toBeVisible() + await expect(modal(p).getByText('live rate')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => modal(p).getByText('live rate').locator('xpath=ancestor::div[1]'), + act: async p => { + // Both sides have to be there: an offline run would show the amount unconverted. + await expect(modal(p).getByText('live rate').locator('xpath=ancestor::div[1]')).toHaveText(/€[\s\S]*[¥¥]/) + }, + }, + { + target: saveExpense, + act: async p => { + await saveExpense(p).click() + await expect(modal(p)).toHaveCount(0) + await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + }, + { + target: p => expRow(p, UPGRADE), + act: async p => { + await expect(expRow(p, UPGRADE)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/) + await expect(expRow(p, SEEDED_EUR)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/) + }, + }, + ], + cleanup: p => deleteExpenses(p, UPGRADE), + }, + + 'filter-costs': { + guide: guide('filter-costs'), + start: openCosts, + steps: [ + { + prepare: async p => { + await typeInto(p, p.getByPlaceholder('Search expenses…'), 'pass') + await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible() + await settle(p) + }, + target: p => p.getByPlaceholder('Search expenses…').locator('xpath=..'), + act: async p => { + await p.getByPlaceholder('Search expenses…').fill('') + await settle(p) + }, + }, + { + prepare: async p => { + await p.getByRole('button', { name: 'All categories' }).first().click() + await beat(p, 300) + await expect(dropdown(p).getByRole('button', { name: 'Transport', exact: true })).toBeVisible() + }, + target: p => p.getByRole('button', { name: 'All categories' }).first(), + act: async p => { + await dropdown(p).getByRole('button', { name: 'Transport', exact: true }).click() + await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0) + await p.getByRole('button', { name: 'Transport', exact: true }).first().click() + await beat(p, 300) + await dropdown(p).getByRole('button', { name: 'All categories' }).click() + await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await p.getByRole('button', { name: 'All days' }).first().click() + await beat(p, 300) + await dropdown(p).getByRole('button', { name: 'Sat, Sep 12' }).click() + await expect(p.getByText(/^Saturday, September 12$/)).toBeVisible() + await settle(p) + }, + target: p => p.getByText(/^Saturday, September 12$/).locator('xpath=../..'), + act: async p => { + await p.getByRole('button', { name: 'Sat, Sep 12' }).first().click() + await beat(p, 300) + await dropdown(p).getByRole('button', { name: 'All days' }).click() + await expect(p.getByText(/^Saturday, September 12$/)).toHaveCount(0) + await settle(p) + }, + }, + { + prepare: async p => { + await p.getByRole('button', { name: 'Paid by me' }).click() + await settle(p) + }, + target: p => p.getByRole('button', { name: 'All', exact: true }).locator('xpath=..'), + act: async p => { + await p.getByRole('button', { name: 'All', exact: true }).click() + await settle(p) + }, + }, + // Clicking it would start a download; the button is what the step is about. + only(p => p.getByRole('button', { name: 'Export CSV' })), + ], + }, + + 'settle-up': { + guide: guide('settle-up'), + start: async p => { + await openCosts(p) + transfersBefore = await settlementIds(p) + }, + steps: [ + only(p => card(p, /^Settle up · /)), + { + target: p => settleRow(p, 'jonas').getByRole('button', { name: 'Settle' }), + act: async p => { + await settleRow(p, 'jonas').getByRole('button', { name: 'Settle' }).click() + await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 }) + await settle(p) + }, + }, + only(p => payRow(p, 'jonas')), + { + target: p => payRow(p, 'jonas').locator('xpath=..').locator('.exp-actions'), + act: async p => { + await payRow(p, 'jonas').locator('xpath=..').getByRole('button', { name: 'Undo' }).click() + await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await p.getByRole('button', { name: 'Add payment' }).first().click() + await expect(modal(p).getByRole('button', { name: 'Add payment' })).toBeVisible() + await modal(p).locator('input[inputmode="decimal"]').first().fill('5000') + await settle(p) + }, + target: dialog, + act: async p => { + await modal(p).getByRole('button', { name: 'Add payment' }).click() + await expect(modal(p)).toHaveCount(0) + await settle(p) + }, + }, + // Ringed and left alone: one click would record every open transfer and + // flatten the balances every later picture is taken against. + only(p => p.getByRole('button', { name: 'Settle up' })), + ], + cleanup: async p => { + const { tripId } = seededTrip() + for (const id of await settlementIds(p)) { + if (!transfersBefore.includes(id)) await p.request.delete(`/api/trips/${tripId}/budget/settlements/${id}`) + } + }, + }, + + 'final-budget': { + guide: guide('final-budget'), + start: openCosts, + steps: [ + only(p => card(p, /^Balances/)), + only(p => card(p, /^Final budget/)), + { + prepare: async p => { + await card(p, /^Final budget/).getByRole('button').first().click() + await expect(p.getByText('Expenses paid').first()).toBeVisible() + await settle(p) + }, + target: p => card(p, /^Final budget/).locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'), + }, + { + // The second "Expenses paid" is the heading of the list the figure is made of. + target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'), + act: async p => { + await card(p, /^Final budget/).getByRole('button').first().click() + await expect(card(p, /^Final budget/).locator('[aria-expanded="true"]')).toHaveCount(0) + }, + }, + ], + }, + + 'expense-from-booking': { + guide: guide('expense-from-booking'), + start: async p => { + await openTrip(p, { tab: 'transports' }) + await expect(p.getByRole('button', { name: 'Edit' }).first()).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + steps: [ + { + target: p => p.getByRole('button', { name: 'Edit' }).first(), + act: async p => { + await p.getByRole('button', { name: 'Edit' }).first().click() + await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible() + await settle(p) + }, + }, + only(p => modal(p).getByText('Saves the booking, then opens the Costs editor.').locator('xpath=..')), + { + target: p => modal(p).getByRole('button', { name: 'Create expense' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Create expense' }).click() + await expect(nameBox(p)).toHaveValue(BOOKING, { timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await amounts(p).first().fill('890') + await pickCurrency(p, 'EUR') + }, + target: saveExpense, + act: async p => { + await saveExpense(p).click() + await expect(modal(p)).toHaveCount(0) + await p.getByRole('button', { name: 'Edit' }).first().click() + await expect(modal(p).getByText('Linked expense')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + await closeModal(p) + await deleteExpenses(p, BOOKING) + }, + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureCostsFixtures(request) +}) + +test('every registered costs guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCostsContext.guides].sort()) +}) + +test('hero: trip-costs', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripCostsContext.id, openCosts) +}) + +for (const id of tripCostsContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-day-detail.guide.ts b/client/e2e/help/trip-day-detail.guide.ts new file mode 100644 index 000000000..d2ca63b17 --- /dev/null +++ b/client/e2e/help/trip-day-detail.guide.ts @@ -0,0 +1,338 @@ +import { test, expect, type Page, type Locator } from '@playwright/test' +import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureDayDetailFixtures, KYOTO_HOTEL, TOKYO_HOTEL, EVENING_WALK } from './fixtures' +import { openTrip } from './trip-shared' +import { tripDayDetailContext, tripDayDetailGuides } from '../../src/help/contexts/tripDayDetail' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the day details panel of a trip, keyed by the ids in + * `src/help/contexts/tripDayDetail.ts`. They run on the seeded "Autumn in + * Japan" with the day fixtures in place, so day 1 carries the flight and the + * Tokyo hotel and day 5 the train and its evening walk; what a guide writes it + * takes back in `cleanup`, so the next one starts from the same seed. + */ + +const guide = (id: string): HelpGuide => { + const g = tripDayDetailGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** The name the rename guide gives day 5, and takes away again. */ +const DAY_NAME = 'Arrival in Kyoto' + +/** The panel itself: the only `fixed z-50` in the client. */ +const panel = (page: Page) => page.locator('div.fixed.z-50') +/** A day's header in the days column; its name is number, weather, title, date. */ +const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }) +/** + * The hotel picker is a portal of its own with neither `trek-modal-backdrop` + * nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it; + * the card is the second `role="presentation"` and holds the day range. + */ +const picker = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Apply to days' }).last() +/** The accommodation block: the heading's parent holds the stays and the add button. */ +const stays = (page: Page) => panel(page).getByText('Accommodation', { exact: true }).locator('xpath=..') +/** One booked stay: a card of the list inside that block. */ +const stayCard = (page: Page, name: string) => stays(page).locator('> div > div').filter({ hasText: name }).first() +/** The caption that opens the weather block. */ +const forecast = (page: Page) => panel(page).getByText(/^Forecast for /) +/** The chips row; the summary above it and the hourly strip below are its siblings. */ +const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[2]') +/** The day's reservations block. */ +const bookings = (page: Page) => panel(page).getByText('Reservations', { exact: true }).locator('xpath=..') +/** One booking row: the title sits in a wrapper, the row is its grandparent. */ +const bookingRow = (page: Page, title: string) => panel(page).getByText(title).locator('xpath=ancestor::div[2]') + +/** + * Select a day by its title rather than by the middle of its header: a day with + * a night booked over it carries a hotel button there, and that button opens the + * place instead of selecting the day. + */ +const selectDay = async (page: Page, n: number): Promise => { + await dayHeader(page, n).getByText(`Day ${n}`, { exact: true }).click() + await expect(panel(page)).toBeVisible() + await settle(page) +} + +/** Open the trip with day `n`'s panel open; `openTrip`'s own day closes it again. */ +const openDay = async (page: Page, n: number): Promise => { + await openTrip(page, { day: null }) + await selectDay(page, n) +} + +interface Stay { + id: number + place_id: number | null + place_name?: string | null + start_day_id: number + end_day_id: number +} + +async function stayOf(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/accommodations`) + const { accommodations } = (await res.json()) as { accommodations: Stay[] } + return accommodations.find(a => a.place_name === name) +} + +/** Cancel a night again. The stop it put on the day and its Hotel booking go with it. */ +async function removeStay(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const stay = await stayOf(page, name) + if (stay) await page.request.delete(`/api/trips/${tripId}/accommodations/${stay.id}`) +} + +/** The update route reads every field off the body, so the whole stay goes back. */ +async function restoreTokyoStay(page: Page): Promise { + const { tripId } = seededTrip() + const stay = await stayOf(page, TOKYO_HOTEL) + if (!stay) return + await page.request.put(`/api/trips/${tripId}/accommodations/${stay.id}`, { + data: { + place_id: stay.place_id, + start_day_id: stay.start_day_id, + end_day_id: stay.end_day_id, + check_in: '15:00', + check_in_end: null, + check_out: '11:00', + confirmation: 'GRC-7731', + }, + }) +} + +/** + * Give day 5 its number back: `selectDay` finds a day by "5 … Day 5 ", which a + * renamed day no longer answers to, and every later guide uses it. + */ +async function resetDayTitle(page: Page, index: number): Promise { + const { tripId, dayIds } = seededTrip() + await page.request.put(`/api/trips/${tripId}/days/${dayIds[index]}`, { data: { title: null } }) +} + +const only = (target: (p: Page) => Locator) => ({ target }) + +const SCRIPTS: Record = { + 'day-panel': { + guide: guide('day-panel'), + start: p => openTrip(p, { day: null }), + steps: [ + { + target: p => dayHeader(p, 1), + act: async p => { + await selectDay(p, 1) + await expect(stays(p)).toBeVisible() + }, + }, + only(p => panel(p).getByText('Day 1', { exact: true }).locator('xpath=ancestor::div[2]')), + { + // The forecast is asked for when the panel opens, and it is a round trip + // to Open-Meteo; without the wait the picture catches the spinner. + prepare: async p => { await expect(forecast(p)).toBeVisible({ timeout: 20_000 }) }, + target: p => forecast(p).locator('xpath=..'), + }, + only(bookings), + only(stays), + { + target: p => panel(p).getByRole('button', { name: 'Collapse' }), + act: async p => { + // Every day card carries a chevron with the same label, so the panel's + // own is reached through the panel. + await panel(p).getByRole('button', { name: 'Collapse' }).click() + await expect(panel(p).getByRole('button', { name: 'Expand' })).toBeVisible() + await expect(stays(p)).toBeHidden() + await settle(p) + }, + }, + ], + }, + 'day-weather': { + guide: guide('day-weather'), + start: async p => { + await openDay(p, 5) + // Day 5 has one located stop, so the forecast is that stop's. + await expect(panel(p).getByText('Forecast for Fushimi Inari Taisha')).toBeVisible({ timeout: 20_000 }) + }, + steps: [ + only(forecast), + only(p => chips(p).locator('xpath=preceding-sibling::div[1]')), + only(chips), + only(p => chips(p).locator('xpath=following-sibling::div[1]')), + ], + }, + 'rename-day': { + guide: guide('rename-day'), + start: p => openDay(p, 5), + steps: [ + only(p => panel(p).getByText('Day 5', { exact: true }).locator('xpath=ancestor::div[2]')), + { + // Every booking row of the open day carries an Edit of its own in the + // days column (DayPlanSidebar), so the header's pencil is reached + // through the panel; inside it, it is the only one. + target: p => panel(p).getByRole('button', { name: 'Edit', exact: true }), + act: async p => { + await panel(p).getByRole('button', { name: 'Edit', exact: true }).click() + await expect(panel(p).getByPlaceholder('Day 5')).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { await typeInto(p, panel(p).getByPlaceholder('Day 5'), DAY_NAME) }, + target: p => panel(p).getByPlaceholder('Day 5'), + act: async p => { + await p.keyboard.press('Enter') + await expect(panel(p).getByText(DAY_NAME, { exact: true })).toBeVisible() + await settle(p) + }, + }, + only(p => p.getByRole('button', { name: new RegExp(`^5 .*${DAY_NAME} `) })), + ], + cleanup: p => resetDayTitle(p, 4), + }, + 'add-accommodation': { + guide: guide('add-accommodation'), + start: p => openTrip(p, { day: null }), + steps: [ + // The fixture put the hotel in the trip as a place; the picker offers + // nothing else, and the rows of the column are options. + only(p => p.getByRole('option', { name: new RegExp(`^${KYOTO_HOTEL.name}`) })), + { + prepare: async p => { await selectDay(p, 5) }, + target: p => p.getByRole('button', { name: 'Add accommodation' }), + act: async p => { + await p.getByRole('button', { name: 'Add accommodation' }).click() + await expect(picker(p)).toBeVisible() + await settle(p) + }, + }, + { + // The options of a day select are portalled to the body, outside the picker. + prepare: async p => { + await picker(p).getByRole('button', { name: /^Day 6/ }).click() + await p.getByRole('button', { name: /^Day 7/ }).last().click() + await expect(picker(p).getByRole('button', { name: /^Day 7/ })).toBeVisible() + await settle(p) + }, + target: p => picker(p).getByText('Apply to days').locator('xpath=..'), + }, + { + // The three time fields normalise what is typed; the labels carry no + // `for`, so every field is found by its placeholder. + prepare: async p => { + await picker(p).getByPlaceholder('14:00').fill('15:00') + await picker(p).getByPlaceholder('22:00').fill('20:00') + await picker(p).getByPlaceholder('11:00').fill('11:00') + await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842') + await settle(p) + }, + target: p => picker(p).getByPlaceholder('ABC-12345').locator('xpath=ancestor::div[2]'), + }, + { + // "All" is both the whole-trip shortcut and the no-filter chip, so the + // category is clicked by its own name. + prepare: async p => { + await picker(p).getByRole('button', { name: 'Hotel', exact: true }).click() + await settle(p) + }, + target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }), + act: async p => { + await picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }).click() + await expect(picker(p).getByRole('button', { name: 'Save' })).toBeEnabled() + }, + }, + { + target: p => picker(p).getByRole('button', { name: 'Save' }), + act: async p => { + await picker(p).getByRole('button', { name: 'Save' }).click() + await expect(picker(p)).toHaveCount(0) + await expect(stayCard(p, KYOTO_HOTEL.name)).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + }, + ], + cleanup: p => removeStay(p, KYOTO_HOTEL.name), + }, + 'edit-accommodation': { + guide: guide('edit-accommodation'), + start: async p => { + // The day fixtures book the Tokyo hotel over the first nights, so day 1 + // is its check-in day. + await openDay(p, 1) + await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 }) + }, + steps: [ + only(p => stayCard(p, TOKYO_HOTEL)), + { + // Neither the pencil nor the × carries a label; the icon is the handle. + target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)'), + act: async p => { + await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click() + await expect(picker(p).getByText('Edit accommodation')).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await picker(p).getByPlaceholder('11:00').fill('10:00') + await picker(p).getByPlaceholder('ABC-12345').click() + await settle(p) + }, + target: p => picker(p).getByPlaceholder('ABC-12345').locator('xpath=ancestor::div[2]'), + }, + { + target: p => picker(p).getByRole('button', { name: 'Save' }), + act: async p => { + await picker(p).getByRole('button', { name: 'Save' }).click() + await expect(picker(p)).toHaveCount(0) + await expect(stayCard(p, TOKYO_HOTEL).getByText('10:00')).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + }, + { + // Deliberately not performed: the × removes the stay without a question, + // and with it the seed's hotel booking and the stop it put on the day. + prepare: async p => { await stayCard(p, TOKYO_HOTEL).hover() }, + target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-x)'), + }, + ], + cleanup: restoreTokyoStay, + }, + 'day-bookings': { + guide: guide('day-bookings'), + start: p => openDay(p, 5), + steps: [ + only(bookings), + only(p => bookingRow(p, EVENING_WALK)), + only(p => bookingRow(p, 'Nozomi 21')), + // Only pointed at: clicking it would leave the plan for the Bookings tab. + only(p => p.getByRole('button', { name: 'Bookings', exact: true })), + ], + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureDayDetailFixtures(request) +}) + +test('every registered day-detail guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDayDetailContext.guides].sort()) +}) + +test('hero: trip-day-detail', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + // Day 1 is the one day that has all four blocks at once: the forecast for its + // first stop, the flight, and the hotel it checks into. + await captureHero(page, tripDayDetailContext.id, p => openDay(p, 1)) +}) + +for (const id of tripDayDetailContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-days.guide.ts b/client/e2e/help/trip-days.guide.ts index 1157ca240..1b85b1b83 100644 --- a/client/e2e/help/trip-days.guide.ts +++ b/client/e2e/help/trip-days.guide.ts @@ -1,7 +1,7 @@ import { test, expect, type Page, type Locator } from '@playwright/test' import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' import { seededTrip, ensureDaysFixtures } from './fixtures' -import { openTrip, modal, dialog, selectDay, closeDayDetails } from './trip-shared' +import { openTrip, openTripOnDay, modal, portalDialog, selectDay, closeDayDetails, closeMenu, dayDetails } from './trip-shared' import { tripDaysContext, tripDaysGuides } from '../../src/help/contexts/tripDays' import type { HelpGuide } from '../../src/help/types' @@ -19,21 +19,62 @@ const guide = (id: string): HelpGuide => { } const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' } +/** The note the day-note guide writes: a name for the card, markdown underneath. */ +const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of the shrine, tickets at the gate.' } /** A day card's header button. */ +/** + * The day the route guides read. Day 6 is the only one whose two stops sit next + * to each other: day 1's are separated by the flight, so it has no leg of its + * own and its only connector is the one to the booked night, which depends on a + * fixture. Day 6 also carries a reservation at a stop, so one card shows every + * row the guide talks about. + */ +const ROUTED_DAY = 6 +/** The pencil that renames the open day, in its details panel. */ +const renameDay = (page: Page) => dayDetails(page).getByRole('button', { name: 'Edit', exact: true }) const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }) -/** A stop row: the button that holds the lock button and the place's name. */ +/** + * A stop row. Every row of a day card is a div with `role="button"`; a stop is + * the one whose accessible name opens with the label of the lock on its + * picture, which is what tells it from the note and booking rows beside it. + */ const stop = (page: Page, name: string) => - page.locator('button').filter({ has: page.getByRole('button', { name: /Keep position during route optimization|Click to unlock/ }) }).filter({ hasText: name }).first() + page + .getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / }) + .filter({ hasText: name }) + .first() +/** A booking row of a day: its phase (Departure / Arrival) and title make its name. */ +const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name }) +/** The note the day-note guide left on day 2, as a row of the card. */ +const noteRow = (page: Page) => page.getByRole('button', { name: new RegExp(`^${NOTE.title}`) }) /** A row of the places column. */ const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() -/** The context menu of a stop or a note. */ -const menu = (page: Page) => page.locator('.trek-popover-enter').last() -/** The route bar of the open day. */ +/** + * The context menu of a stop, a note or a leg. Tooltip renders into the same + * `.trek-popover-enter` class and a click on a connector leaves its tooltip + * standing, so the menu is the popover that is not one. + */ +const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last() +/** The route bar of the open day: Route, the two hand-offs, Optimize, the modes. */ const routeBar = (page: Page) => page.getByRole('button', { name: 'Route', exact: true }).locator('xpath=..') const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).locator('xpath=..') -/** A connector between two stops, once the day's route is computed. */ -const connector = (page: Page) => page.locator('div[role="button"]').filter({ has: page.locator('svg.lucide-car, svg.lucide-footprints') }).first() +/** + * A connector between two stops, once the day's route is computed. The hotel + * bookend legs carry the same label and are not between two stops, so they are + * filtered out by the hotel icon only they have. + */ +/** + * A leg between two rows of a day, ringed by its travel time. Day 1's is the one + * to the booked night: the flight sits between its two stops, so they have no + * leg of their own, and the day fixtures put a hotel on the first four nights. + */ +const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first() +/** The note dialog: its own portal with no backdrop class, known by its body field. */ +const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…') +const noteDialog = (page: Page) => portalDialog(page, noteBody(page)) +/** The question a timed stop asks before it moves; also a portal of its own. */ +const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?')) async function createSpare(page: Page): Promise { const { tripId } = seededTrip() @@ -51,9 +92,13 @@ async function deleteByName(page: Page, ...names: string[]): Promise { } } -/** Open the trip on day 1 and draw its route, so the connectors between the stops exist. */ +/** + * Open the trip on day 1 and draw its route, so the connectors between the + * stops exist. The day stays selected with its details panel up: the panel is + * the selection, and the route tools render for the selected day only. + */ async function openWithRoute(page: Page): Promise { - await openTrip(page) + await openTripOnDay(page, ROUTED_DAY) await page.getByRole('button', { name: 'Route', exact: true }).click() await expect(connector(page)).toBeVisible({ timeout: 30_000 }) await settle(page) @@ -73,9 +118,9 @@ const SCRIPTS: Record = { guide: guide('read-day-plan'), start: openWithRoute, steps: [ - only(p => dayHeader(p, 1)), - only(p => stop(p, 'Senso-ji Temple')), - only(p => p.locator('button').filter({ hasText: /Departure.*LH716/ }).first()), + only(p => dayHeader(p, ROUTED_DAY)), + only(p => stop(p, 'Arashiyama Bamboo Grove')), + only(p => stop(p, 'Nishiki Market')), only(connector), only(routeBar), ], @@ -84,7 +129,9 @@ const SCRIPTS: Record = { guide: guide('place-onto-day'), start: async p => { await createSpare(p) - await openTrip(p) + // Day 1 stays open: the "+" of a place row and "To day" exist only while + // a day is selected, and steps 2 and 4 are about those two. + await openTripOnDay(p, 1) }, steps: [ { @@ -97,15 +144,10 @@ const SCRIPTS: Record = { }, }, { - prepare: async p => { - // Back to the list for the second way in. - await stop(p, SPARE.name).click({ button: 'right' }) - await menu(p).getByRole('button', { name: 'Remove from day' }).click() - await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 }) - await placeRow(p, SPARE.name).hover() - await settle(p) - }, - target: p => placeRow(p, SPARE.name).locator('button').last(), + // The "+" offers the OPEN day, so it is still there with the place on + // day 2 — and the place stays on a day, which is what the result says. + prepare: async p => { await placeRow(p, SPARE.name).hover() }, + target: p => placeRow(p, SPARE.name).getByRole('button'), }, only(p => p.getByRole('button', { name: 'Add place to this day' }).first()), only(p => p.getByRole('button', { name: 'Add to the open day' })), @@ -130,14 +172,22 @@ const SCRIPTS: Record = { }, { prepare: async p => { - // Moving the timed stop asks first; the question is the picture. - await stop(p, 'Senso-ji Temple').hover() - await stop(p, 'Senso-ji Temple').locator('button:has(svg.lucide-chevron-down)').click() - await expect(modal(p).getByText('Remove time?')).toBeVisible({ timeout: 10_000 }) + // Only a move that breaks the day's chronology asks, and the arrows + // never do here: the timed stop is the first row of the day. Dropping + // it below the 13:05 departure does, and Cancel leaves the day alone. + const booking = bookingRow(p, /Departure.*LH716/) + const box = await booking.boundingBox() + if (!box) throw new Error('the departure row of day 1 has no box') + await stop(p, 'Senso-ji Temple').dragTo(booking, { targetPosition: { x: box.width / 2, y: box.height - 4 } }) + await expect(timeConfirm(p)).toBeVisible({ timeout: 10_000 }) + await settle(p) + }, + target: timeConfirm, + act: async p => { + await timeConfirm(p).getByRole('button', { name: 'Cancel' }).click() + await expect(p.getByText('Remove time?')).toHaveCount(0) await settle(p) }, - target: dialog, - act: closeModal, }, ], }, @@ -195,9 +245,10 @@ const SCRIPTS: Record = { guide: guide('remove-from-day'), start: async p => { await createSpare(p) - await openTrip(p) + // The "+" puts the place on the OPEN day, so day 1 stays selected. + await openTripOnDay(p, 1) await placeRow(p, SPARE.name).hover() - await placeRow(p, SPARE.name).locator('button').last().click() + await placeRow(p, SPARE.name).getByRole('button').click() await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 }) await settle(p) }, @@ -248,34 +299,37 @@ const SCRIPTS: Record = { target: p => dayHeader(p, 2).getByRole('button', { name: 'Add Note' }), act: async p => { await dayHeader(p, 2).getByRole('button', { name: 'Add Note' }).click() - await expect(modal(p).getByPlaceholder('Details, links, reminders…')).toBeVisible() + await expect(noteBody(p)).toBeVisible() await settle(p) }, }, { prepare: async p => { - await typeInto(p, modal(p).getByPlaceholder('Details, links, reminders…'), 'Rain plan: **Tokyo National Museum** instead of the shrine, tickets at the gate.') + // Note is the name the day card shows and the only required field; + // Daily Note below it is the markdown the toolbar formats. + await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title) + await typeInto(p, noteBody(p), NOTE.body) await beat(p, 300) }, - target: p => modal(p).getByPlaceholder('Details, links, reminders…').locator('xpath=ancestor::div[2]'), + target: p => noteBody(p).locator('xpath=ancestor::div[2]'), }, { - target: p => modal(p).getByText('Colour', { exact: true }).locator('xpath=..'), + target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'), act: async p => { - await modal(p).getByRole('button', { name: 'Save', exact: true }).click() - await expect(modal(p)).toHaveCount(0) - await expect(p.getByText('Rain plan:')).toBeVisible({ timeout: 15_000 }) + await noteDialog(p).getByRole('button', { name: 'Add', exact: true }).click() + await expect(noteDialog(p)).toHaveCount(0) + await expect(noteRow(p)).toBeVisible({ timeout: 15_000 }) await settle(p) }, }, { prepare: async p => { - await p.locator('button').filter({ hasText: 'Rain plan:' }).first().click({ button: 'right' }) + await noteRow(p).click({ button: 'right' }) await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible() await beat(p, 300) }, - target: p => p.locator('button').filter({ hasText: 'Rain plan:' }).first(), - act: async p => { await p.keyboard.press('Escape') }, + target: noteRow, + act: closeMenu, }, ], cleanup: async p => { @@ -283,14 +337,14 @@ const SCRIPTS: Record = { const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[1]}/notes`) const body = (await res.json()) as { notes?: { id: number; text: string }[] } | { id: number; text: string }[] const notes = Array.isArray(body) ? body : (body.notes ?? []) - for (const note of notes.filter(n => n.text.startsWith('Rain plan:'))) { + for (const note of notes.filter(n => n.text.startsWith(NOTE.title))) { await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`) } }, }, 'day-route': { guide: guide('day-route'), - start: p => openTrip(p), + start: p => openTripOnDay(p, ROUTED_DAY), steps: [ { target: p => p.getByRole('button', { name: 'Route', exact: true }), @@ -308,10 +362,12 @@ const SCRIPTS: Record = { await beat(p, 300) }, target: menu, - act: async p => { await p.keyboard.press('Escape') }, + act: closeMenu, }, only(p => p.getByRole('button', { name: 'Optimize' })), - only(p => p.getByRole('button', { name: 'Open in Google Maps' }).locator('xpath=..')), + // The two hand-offs are siblings with no wrapper of their own: their + // parent is the whole route bar, which is already read-day-plan's step 5. + only(p => p.getByRole('button', { name: 'Open in Google Maps' })), ], }, 'manage-days': { @@ -331,10 +387,12 @@ const SCRIPTS: Record = { { prepare: async p => { await selectDay(p, 1) - await expect(p.getByRole('button', { name: 'Edit', exact: true })).toBeVisible() + await expect(renameDay(p)).toBeVisible() await settle(p) }, - target: p => p.getByRole('button', { name: 'Edit', exact: true }).locator('xpath=..'), + // Scoped to the panel: a stop row carries an Edit of its own, so the + // bare name matches two buttons as soon as any day is unfolded. + target: p => renameDay(p).locator('xpath=..'), act: closeDayDetails, }, only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })), @@ -346,7 +404,7 @@ const SCRIPTS: Record = { await openTrip(p, { day: 5 }) }, steps: [ - only(p => p.locator('button').filter({ hasText: /Departure.*Nozomi 21/ }).first()), + only(p => bookingRow(p, /Departure.*Nozomi 21/)), { prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) }, target: p => stop(p, 'Nishiki Market'), @@ -401,7 +459,9 @@ test('every registered days guide has a script, and only those', async () => { test('hero: trip-days', async ({ page }) => { await page.setViewportSize(VIEWPORT) - await captureHero(page, tripDaysContext.id, openWithRoute) + // The plan as it opens: the details panel is trip-day-detail's subject and the + // route is day-route's, so the hero shows neither. + await captureHero(page, tripDaysContext.id, p => openTrip(p)) }) for (const id of tripDaysContext.guides) { diff --git a/client/e2e/help/trip-files.guide.ts b/client/e2e/help/trip-files.guide.ts new file mode 100644 index 000000000..58c511687 --- /dev/null +++ b/client/e2e/help/trip-files.guide.ts @@ -0,0 +1,443 @@ +import { test, expect, type Page, type Locator } from '@playwright/test' +import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture } from './fixtures' +import { openTrip, portalDialog } from './trip-shared' +import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the Files tab of a trip, keyed by the ids in + * `src/help/contexts/tripFiles.ts`. The seed creates no documents at all, so + * `ensureFilesFixtures` puts a small set of drawn ones in before the first + * picture; what a guide uploads, links, stars or throws away it undoes in + * `cleanup`, so the next one starts from that same set. + */ + +const guide = (id: string): HelpGuide => { + const g = tripFilesGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** The fixture documents each guide acts on, by the name their row carries. */ +const STARRED = 'JR-Pass-voucher.pdf' +const LINKED = 'LH716-boarding-pass.pdf' +const DOOMED = 'old-draft-itinerary.pdf' +const PHOTO = 'nishiki-market.jpg' +const DOCUMENT = 'travel-notes.md' +/** What the upload guide brings in; these two names exist nowhere else, so cleanup can take them out. */ +const UPLOADED_PDF = 'kyoto-bus-pass.pdf' +const UPLOADED_PHOTO = 'arashiyama.jpg' +const UPLOADED = [UPLOADED_PDF, UPLOADED_PHOTO] +/** What the link guide ties the boarding pass to, spelled as the dialog spells them. */ +const PLACE = 'teamLab Planets' +const FLIGHT = 'LH716 FRA → HND' +const NOTE = 'Seat 34K, boarding 12:20' + +const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' }) + +/** A file row: the only `.group` block the Files panel renders, one per file. */ +const row = (page: Page, name: string) => page.locator('div.group').filter({ hasText: name }).first() +/** A row's action buttons take their accessible name from their `title`. */ +const action = (page: Page, name: string, title: string) => + row(page, name).getByRole('button', { name: title, exact: true }) +/** react-dropzone always sets `role="presentation"` on the box it wraps. */ +const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first() +/** The filter row, found through the one tab that is always there. */ +const tabRow = (page: Page) => page.getByRole('button', { name: /^All \d+$/ }).locator('xpath=..') +/** The star tab has no text: its accessible name is the count alone. */ +const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: page.locator('svg.lucide-star') }).first() +/** + * The number on a filter tab. Reading it rather than counting the fixture keeps + * the step honest about the one thing it is really claiming: that the tab shows + * exactly as many rows as its badge promises, and it survives another screen's + * fixture adding a file of its own. + */ +const tabCount = async (tab: Locator): Promise => Number(((await tab.textContent()) ?? '').replace(/\D+/g, '')) +/** Trash and Empty Trash both contain "Trash", so the toolbar's own button needs `exact`. */ +const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true }) +/** Assign File is a raw portal with no shared backdrop class; `portalDialog` finds its card. */ +const assignDialog = (page: Page) => portalDialog(page, page.getByText('Assign File', { exact: true })) +const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...') +/** + * The lightbox, found by the one header button only it and the document preview + * carry, and they are never open at the same time. Its three direct children + * are the header, the picture and the strip of thumbnails. + */ +const lightbox = (page: Page) => + page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first() +/** The preview for everything that is not a picture; markdown renders in `.collab-note-md`. */ +const docPreview = (page: Page) => portalDialog(page, page.locator('.collab-note-md')) +/** + * The × of a portal. The assign dialog and the document preview have no key + * handler at all, so it is their only way out; the lightbox does listen for + * Escape, which is what its own step uses. + */ +const closeX = (dialog: Locator) => dialog.locator('button:has(svg.lucide-x)').first() + +interface ApiFile { + id: number + original_name: string + starred?: number +} + +async function listFiles(page: Page, trash = false): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`) + const body = (await res.json()) as { files?: ApiFile[] } + return body.files ?? [] +} + +/** Take the guide's own uploads out of the trip for good: to the trash, then off the disk. */ +async function removeFiles(page: Page, names: string[]): Promise { + const { tripId } = seededTrip() + for (const file of (await listFiles(page)).filter(f => names.includes(f.original_name))) { + await page.request.delete(`/api/trips/${tripId}/files/${file.id}`) + } + for (const file of (await listFiles(page, true)).filter(f => names.includes(f.original_name))) { + await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`) + } +} + +/** Put the note and every link the guide made back to nothing. */ +async function unlinkFile(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const file = (await listFiles(page)).find(f => f.original_name === name) + if (!file) return + await page.request.put(`/api/trips/${tripId}/files/${file.id}`, { + data: { place_id: null, reservation_id: null, description: '' }, + }) + const res = await page.request.get(`/api/trips/${tripId}/files/${file.id}/links`) + const { links = [] } = (await res.json()) as { links?: { id: number }[] } + for (const link of links) { + await page.request.delete(`/api/trips/${tripId}/files/${file.id}/link/${link.id}`) + } +} + +/** The star route toggles, so it is only called back when the file really is starred. */ +async function unstarFile(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const file = (await listFiles(page)).find(f => f.original_name === name) + if (file?.starred) await page.request.patch(`/api/trips/${tripId}/files/${file.id}/star`) +} + +/** Bring a file the guide threw away back out of the trash, if it is still in there. */ +async function untrashFile(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const file = (await listFiles(page, true)).find(f => f.original_name === name) + if (file) await page.request.post(`/api/trips/${tripId}/files/${file.id}/restore`) +} + +const only = (target: (p: Page) => Locator) => ({ target }) + +const SCRIPTS: Record = { + 'files-upload': { + guide: guide('files-upload'), + // Starts on the plan, because the first step is reaching the tab. + start: p => openTrip(p, { day: null }), + steps: [ + { + target: p => p.getByRole('button', { name: 'Files', exact: true }), + act: async p => { + await p.getByRole('button', { name: 'Files', exact: true }).click() + await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 }) + await expect(dropzone(p)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: dropzone, + // The hidden input takes the files directly: clicking the box would open + // the operating system's picker, which Playwright cannot answer. + act: async p => { + await dropzone(p).locator('input[type="file"]').setInputFiles([ + filesPdfFixture('kyoto-bus-pass'), + await filesImageFixture('arashiyama'), + ]) + await expect(assignDialog(p)).toBeVisible({ timeout: 60_000 }) + await settle(p) + }, + }, + { + target: assignDialog, + act: async p => { + await closeX(assignDialog(p)).click() + await expect(assignDialog(p)).toHaveCount(0) + await settle(p) + }, + }, + { + prepare: async p => { + await expect(row(p, UPLOADED_PHOTO)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + target: p => row(p, UPLOADED_PHOTO), + }, + ], + cleanup: p => removeFiles(p, UPLOADED), + }, + 'files-link': { + guide: guide('files-link'), + start: openFiles, + steps: [ + { + prepare: async p => { + await row(p, LINKED).hover() + }, + target: p => action(p, LINKED, 'Assign'), + act: async p => { + await action(p, LINKED, 'Assign').click() + await expect(assignDialog(p)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await typeInto(p, noteBox(p), NOTE) + }, + // The label above the box belongs in the picture, so the step rings the + // block rather than the input. + target: p => noteBox(p).locator('xpath=..'), + // The note is written on blur, and Enter blurs; the row behind the + // dialog picks it up, which is the proof that it was saved. + act: async p => { + await noteBox(p).press('Enter') + await expect(row(p, LINKED).getByText(NOTE)).toBeVisible({ timeout: 20_000 }) + }, + }, + { + target: p => assignDialog(p).getByRole('button', { name: PLACE }), + act: async p => { + await assignDialog(p).getByRole('button', { name: PLACE }).click() + await expect(assignDialog(p).getByRole('button', { name: PLACE }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 }) + }, + }, + { + target: p => assignDialog(p).getByRole('button', { name: FLIGHT }), + act: async p => { + await assignDialog(p).getByRole('button', { name: FLIGHT }).click() + await expect(assignDialog(p).getByRole('button', { name: FLIGHT }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 }) + }, + }, + { + target: p => closeX(assignDialog(p)), + act: async p => { + await closeX(assignDialog(p)).click() + await expect(assignDialog(p)).toHaveCount(0) + await expect(row(p, LINKED).getByText(`Transport · ${FLIGHT}`)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => unlinkFile(p, LINKED), + }, + 'files-star': { + guide: guide('files-star'), + start: openFiles, + steps: [ + { + prepare: async p => { + await row(p, STARRED).hover() + }, + target: p => action(p, STARRED, 'Star'), + act: async p => { + await action(p, STARRED, 'Star').click() + await expect(action(p, STARRED, 'Unstar')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + // Sorting is the server's (starred first, then newest first), so the + // list is only right once the reload has come back. + prepare: async p => { + await expect(p.locator('div.group').first()).toContainText(STARRED, { timeout: 20_000 }) + await settle(p) + }, + target: p => p.locator('div.group').first(), + }, + { + target: starTab, + act: async p => { + const starred = await tabCount(starTab(p)) + await starTab(p).click() + await expect(p.locator('div.group')).toHaveCount(starred) + await settle(p) + }, + }, + ], + cleanup: p => unstarFile(p, STARRED), + }, + 'files-filter': { + guide: guide('files-filter'), + start: openFiles, + steps: [ + only(tabRow), + { + target: p => p.getByRole('button', { name: /^PDFs \d+$/ }), + act: async p => { + const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ })) + await p.getByRole('button', { name: /^PDFs \d+$/ }).click() + await expect(p.locator('div.group')).toHaveCount(pdfs) + await settle(p) + }, + }, + { + target: p => p.getByRole('button', { name: /^Collab Notes \d+$/ }), + act: async p => { + await p.getByRole('button', { name: /^Collab Notes \d+$/ }).click() + await expect(p.getByText('From Collab Notes')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => p.getByRole('button', { name: /^All \d+$/ }), + act: async p => { + const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ })) + await p.getByRole('button', { name: /^All \d+$/ }).click() + await expect(p.locator('div.group')).toHaveCount(all) + await settle(p) + }, + }, + ], + }, + 'files-preview': { + guide: guide('files-preview'), + start: openFiles, + steps: [ + { + prepare: async p => { + await row(p, PHOTO).hover() + }, + // Two buttons in the row carry the file's name: the thumbnail and the + // name itself. The reader is told about the name, which comes second. + target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(), + act: async p => { + await row(p, PHOTO).getByRole('button', { name: PHOTO }).last().click() + await expect(p.getByRole('img', { name: PHOTO })).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + { + target: p => lightbox(p).locator('> div').last(), + // Which way the picture can move depends on where it sits in the strip, + // and the right chevron is there only while there is a next one. + act: async p => { + const forward = (await lightbox(p).locator('svg.lucide-chevron-right').count()) > 0 + await p.keyboard.press(forward ? 'ArrowRight' : 'ArrowLeft') + await expect(p.getByRole('img', { name: PHOTO })).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + // The header's button group, not the whole header bar: the bar runs the + // full width of the frame, so ringing it rings mostly empty space. + target: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).locator('xpath=..'), + act: async p => { + await p.keyboard.press('Escape') + await expect(lightbox(p)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await row(p, DOCUMENT).getByRole('button', { name: DOCUMENT }).last().click() + await expect(docPreview(p)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + target: docPreview, + act: async p => { + await closeX(docPreview(p)).click() + await expect(docPreview(p)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await row(p, STARRED).hover() + }, + target: p => action(p, STARRED, 'Download'), + // The button fetches the bytes and clicks a synthetic anchor, so the + // browser reports a real download. + act: async p => { + const download = p.waitForEvent('download', { timeout: 30_000 }) + await action(p, STARRED, 'Download').click() + await (await download).delete() + await settle(p) + }, + }, + ], + }, + 'files-trash': { + guide: guide('files-trash'), + start: openFiles, + steps: [ + { + prepare: async p => { + await row(p, DOOMED).hover() + }, + target: p => action(p, DOOMED, 'Delete'), + act: async p => { + await action(p, DOOMED, 'Delete').click() + await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + target: trashTab, + act: async p => { + await trashTab(p).click() + await expect(p.getByRole('heading', { name: 'Trash' })).toBeVisible({ timeout: 20_000 }) + await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + only(p => row(p, DOOMED)), + { + target: p => action(p, DOOMED, 'Restore'), + act: async p => { + await action(p, DOOMED, 'Restore').click() + await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + // Empty Trash is there because the fixture leaves one document in the + // trash. It asks with the browser's own confirm, which Playwright + // dismisses, so clicking it would do nothing at all; the step shows the + // button and leaves the trash by the toolbar instead. + target: p => p.getByRole('button', { name: 'Empty Trash' }), + act: async p => { + await trashTab(p).click() + await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 }) + await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => untrashFile(p, DOOMED), + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureFilesFixtures(request) +}) + +test('every registered files guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripFilesContext.guides].sort()) +}) + +test('hero: trip-files', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripFilesContext.id, openFiles) +}) + +for (const id of tripFilesContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-lists.guide.ts b/client/e2e/help/trip-lists.guide.ts new file mode 100644 index 000000000..b64ad8e63 --- /dev/null +++ b/client/e2e/help/trip-lists.guide.ts @@ -0,0 +1,665 @@ +import { test, expect, type Page, type Locator } from '@playwright/test' +import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureListsFixtures } from './fixtures' +import { openTrip, modal } from './trip-shared' +import { tripListsContext, tripListsGuides } from '../../src/help/contexts/tripLists' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the Lists tab of a trip, keyed by the ids in + * `src/help/contexts/tripLists.ts`. They run on the seeded "Autumn in Japan" + * with the fixtures' packing template and two extra tasks in place, so Apply + * template exists and the to-do sidebar has more than one filled filter; what + * a guide creates it takes away again in `cleanup`, so the next one starts the + * same. + */ + +const guide = (id: string): HelpGuide => { + const g = tripListsGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** What the guides make and unmake. Nothing here is in the seed. */ +const NEW_LIST = 'Toiletries' +const FIRST_ITEM = 'Toothbrush' +const RENAMED_ITEM = 'Toothbrush and paste' +const MEMBER = 'mira' +const SHARED_ITEM = 'Camera' +const BAG_ITEM = 'Rain jacket' +const BAG = 'Cabin bag' +const SAVED_TEMPLATE = 'Autumn in Japan' +const NEW_TASK = 'Pick up the pocket wifi' +const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.' +const TASK_LIST = 'On arrival' +const OTHER_TASK_LIST = 'Before departure' +const EDITED_TASK = 'Activate JR Pass' +const ASSIGNEE = 'jonas' +/** The date picker labels a day cell with the whole date, in the run's English. */ +const DUE_DAY = 'September 25, 2026' +/** The fixtures' template, and the lists it brings with it. */ +const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag', 'SIM card', 'Plug adapter'] } +const IMPORT_LINES = 'Toiletries, Toothbrush\nToiletries, Sunscreen, 90\nHealth, Plasters, 30' +const IMPORTED = ['Toothbrush', 'Sunscreen', 'Plasters'] +/** The seed's packing items, in the order it created them. */ +const SEED_ORDER = ['Passport', 'JR Pass voucher', 'Travel insurance', 'Rain jacket', 'Walking shoes', 'Light layers', 'Type-A adapter', 'Power bank', 'Camera'] +const DOCUMENTS = ['Passport', 'JR Pass voucher', 'Travel insurance'] +/** The count in the label moves with what is ticked, so it is matched, not spelled. */ +const CLEAR_CHECKED = /^Remove \d+ checked$/ + +// ── Locators ───────────────────────────────────────────────────────────────── + +/** A category card: the list's name is a span in the card's header row. */ +const cat = (page: Page, name: string): Locator => + page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]') +/** + * The header row of a card. The ⋯ has to be taken from here: every item row + * carries a phone-only twin of it that `display:none` hides but CSS still + * matches, so the card as a whole holds four of them. + */ +const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first() +const catMenu = (page: Page, name: string): Locator => + catHeader(page, name).locator('button:has(svg.lucide-more-horizontal)') +/** The open ⋯ menu is a fixed popover with no class of its own; its last entry names it. */ +const catMenuCard = (page: Page): Locator => page.getByRole('button', { name: 'Delete List' }).locator('xpath=..') +const item = (page: Page, name: string): Locator => + page.locator('.packing-item-row').filter({ hasText: name }).first() +/** The only item of the list the first guide builds; its name changes mid-guide. */ +const onlyRow = (page: Page): Locator => cat(page, NEW_LIST).locator('.packing-item-row').first() +/** The sharing dropdown is portalled to , so the hint's parent is the card itself. */ +const shareMenu = (page: Page): Locator => + page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]') +/** Its click-catcher, the only way back out of the menu once a name is ticked. */ +const shareOverlay = (page: Page): Locator => page.locator('div[role="presentation"][style*="z-index: 1099"]').last() +/** The bulk-import dialog: its own portal, with none of the shared backdrop classes. */ +const importCard = (page: Page): Locator => + page.getByText('Import Packing List', { exact: true }).locator('xpath=..') +/** The bags column. Total weight is a span two divs inside it, under the rule. */ +const bagSidebar = (page: Page): Locator => + page.getByText('Total weight', { exact: true }).locator('xpath=ancestor::div[3]') +/** The detail pane on the right; its header reads Task. */ +const todoPane = (page: Page): Locator => page.getByText('Task', { exact: true }).locator('xpath=ancestor::div[2]') +/** A task row is a div with role=button whose name starts with the task's own. */ +const task = (page: Page, name: string): Locator => + page.getByRole('button', { name: new RegExp(`^${name}`) }).first() +const sidebarFilter = (page: Page, name: string): Locator => + page.getByRole('button', { name: new RegExp(`^${name}`) }) + +const only = (target: (p: Page) => Locator) => ({ target }) + +// ── Getting there ──────────────────────────────────────────────────────────── + +/** The tab id in the address is the legacy 'listen'; the tab itself reads Lists. */ +async function openLists(page: Page): Promise { + await openTrip(page, { tab: 'listen' }) + await expect(page.getByRole('button', { name: /^Packing List/ })).toBeVisible({ timeout: 30_000 }) + await settle(page) +} + +/** + * The sub-tab is remembered in sessionStorage, which Playwright never restores, + * so every guide opens on Packing List and the to-do ones switch over. + */ +async function openTodos(page: Page): Promise { + await openLists(page) + await page.getByRole('button', { name: /^To-Do/ }).click() + await expect(page.getByRole('button', { name: 'Add new task' })).toBeVisible({ timeout: 15_000 }) + await settle(page) +} + +// ── Putting the seed back ──────────────────────────────────────────────────── + +interface PackingRow { + id: number + name: string + category: string | null +} + +async function packingItems(page: Page): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/packing`) + const body = (await res.json()) as { items?: PackingRow[] } + return body.items ?? [] +} + +async function deleteItems(page: Page, match: (row: PackingRow) => boolean): Promise { + const { tripId } = seededTrip() + for (const row of (await packingItems(page)).filter(match)) { + await page.request.delete(`/api/trips/${tripId}/packing/${row.id}`) + } +} + +/** + * Re-creates the three Documents items the guide really deleted, then writes + * the seed's order back: without it the re-created rows sort last, the + * Documents card slides to the end of the two-column grid, and every later + * guide's picture changes. + */ +async function restoreDocuments(page: Page): Promise { + const { tripId } = seededTrip() + const present = await packingItems(page) + for (const name of DOCUMENTS) { + if (present.some(row => row.name === name)) continue + await page.request.post(`/api/trips/${tripId}/packing`, { data: { name, category: 'Documents', visibility: 'common' } }) + } + const after = await packingItems(page) + const orderedIds = SEED_ORDER.map(name => after.find(row => row.name === name)?.id).filter((id): id is number => id !== undefined) + await page.request.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds } }) +} + +async function deleteTemplate(page: Page, name: string): Promise { + const res = await page.request.get('/api/admin/packing-templates') + const body = (await res.json()) as { templates?: { id: number; name: string }[] } + for (const template of (body.templates ?? []).filter(row => row.name === name)) { + await page.request.delete(`/api/admin/packing-templates/${template.id}`) + } +} + +async function deleteTodo(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/todo`) + const body = (await res.json()) as { items?: { id: number; name: string }[] } + for (const row of (body.items ?? []).filter(x => x.name === name)) { + await page.request.delete(`/api/trips/${tripId}/todo/${row.id}`) + } +} + +async function unassignTodo(page: Page, name: string): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/todo`) + const body = (await res.json()) as { items?: { id: number; name: string }[] } + const row = (body.items ?? []).find(x => x.name === name) + if (row) await page.request.put(`/api/trips/${tripId}/todo/${row.id}`, { data: { assigned_user_id: null } }) +} + +// ── Scripts ────────────────────────────────────────────────────────────────── + +const SCRIPTS: Record = { + 'packing-categories': { + guide: guide('packing-categories'), + start: openLists, + steps: [ + { + target: p => p.getByRole('button', { name: 'Add list' }), + act: async p => { + await p.getByRole('button', { name: 'Add list' }).click() + const field = p.getByPlaceholder('List name (e.g. Clothing)') + await typeInto(p, field, NEW_LIST) + await field.locator('xpath=..').locator('button:has(svg.lucide-check)').click() + await expect(cat(p, NEW_LIST)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }), + act: async p => { + await cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }).click() + const field = p.getByPlaceholder('Item name...') + await typeInto(p, field, FIRST_ITEM) + // Enter renames the list's '...' row rather than adding a second one, + // and leaves the field open; Escape is how it closes. + await field.press('Enter') + await expect(item(p, FIRST_ITEM)).toBeVisible({ timeout: 20_000 }) + await field.press('Escape') + await settle(p) + }, + }, + { + prepare: async p => { await onlyRow(p).hover() }, + target: p => onlyRow(p).getByRole('button', { name: 'Rename' }), + act: async p => { + await onlyRow(p).getByRole('button', { name: 'Rename' }).click() + // While editing, the name is the row's first input; the quantity and + // the weight come after it. + const field = onlyRow(p).locator('input').first() + await typeInto(p, field, RENAMED_ITEM) + await field.press('Enter') + await expect(item(p, RENAMED_ITEM)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)'), + act: async p => { + const plus = catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)') + await plus.click() + await catHeader(p, NEW_LIST).getByRole('button', { name: MEMBER }).click() + await expect(catHeader(p, NEW_LIST).locator('.assignee-chip')).toHaveCount(1, { timeout: 20_000 }) + await plus.click() + await settle(p) + }, + }, + { + prepare: async p => { + await catMenu(p, NEW_LIST).click() + await expect(p.getByRole('button', { name: 'Delete List' })).toBeVisible() + await beat(p, 300) + }, + target: catMenuCard, + act: async p => { + // The menu sits over a full-viewport catcher; a click in the corner + // closes it without deleting the list the picture was just taken of. + await p.mouse.click(20, VIEWPORT.height - 40) + await expect(p.getByRole('button', { name: 'Delete List' })).toHaveCount(0) + await settle(p) + }, + }, + ], + cleanup: async p => { + const { tripId } = seededTrip() + await deleteItems(p, row => row.category === NEW_LIST) + await p.request.put(`/api/trips/${tripId}/packing/category-assignees/${encodeURIComponent(NEW_LIST)}`, { data: { user_ids: [] } }) + }, + }, + 'check-off-packing': { + guide: guide('check-off-packing'), + start: openLists, + steps: [ + { + // The grip is a div, so the tick box is the row's first button. + target: p => item(p, 'Passport').getByRole('button').first(), + act: async p => { + await item(p, 'Passport').getByRole('button').first().click() + // Both box SVGs stay in the DOM and cross-fade, so the assertion is on + // the name instead: a checked row renders it as a span, not a button. + await expect(item(p, 'Passport').getByRole('button', { name: 'Passport' })).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')), + { + prepare: async p => { + await catMenu(p, 'Documents').click() + await expect(p.getByRole('button', { name: 'Check All', exact: true })).toBeVisible() + await beat(p, 300) + }, + target: p => p.getByRole('button', { name: 'Check All', exact: true }), + act: async p => { + await p.getByRole('button', { name: 'Check All', exact: true }).click() + await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => p.getByRole('button', { name: 'Open', exact: true }), + act: async p => { + await p.getByRole('button', { name: 'Open', exact: true }).click() + await expect(p.getByText('Documents', { exact: true })).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => p.getByRole('button', { name: CLEAR_CHECKED }), + act: async p => { + // A native window.confirm, which Playwright dismisses unless something + // accepts it first. The browser's own dialog cannot be pictured, so + // the step shows the button and the state it leaves behind. + p.once('dialog', d => { void d.accept() }) + await p.getByRole('button', { name: CLEAR_CHECKED }).click() + await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 }) + await settle(p) + }, + }, + ], + cleanup: restoreDocuments, + }, + 'apply-packing-template': { + guide: guide('apply-packing-template'), + start: openLists, + steps: [ + { + target: p => p.getByRole('button', { name: 'Apply template' }), + act: async p => { + await p.getByRole('button', { name: 'Apply template' }).click() + await expect(p.locator('.trek-menu-enter').last()).toBeVisible() + await settle(p) + }, + }, + { + target: p => p.locator('.trek-menu-enter').last(), + act: async p => { + await p.locator('.trek-menu-enter').last().getByRole('button', { name: new RegExp(`^${TEMPLATE.name}`) }).click() + await expect(cat(p, TEMPLATE.category)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + only(p => p.getByRole('button', { name: /^Shared/ })), + { + target: p => p.getByRole('button', { name: 'Save as template' }), + act: async p => { + await p.getByRole('button', { name: 'Save as template' }).click() + const field = p.getByPlaceholder('Template name') + await typeInto(p, field, SAVED_TEMPLATE) + await field.locator('xpath=..').locator('button:has(svg.lucide-check)').click() + await expect(p.getByText('Packing list saved as template')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + await deleteItems(p, row => TEMPLATE.items.includes(row.name)) + await deleteTemplate(p, SAVED_TEMPLATE) + }, + }, + 'import-packing-list': { + guide: guide('import-packing-list'), + start: openLists, + steps: [ + { + target: p => p.getByRole('button', { name: 'Import', exact: true }), + act: async p => { + await p.getByRole('button', { name: 'Import', exact: true }).click() + await expect(importCard(p)).toBeVisible() + await settle(p) + }, + }, + { + // The step's text points at the grey sample, which is the textarea's + // placeholder: it is only on screen while the box is empty, so the lines + // are typed after the picture, not before it. + target: importCard, + act: async p => { + await typeInto(p, importCard(p).locator('textarea'), IMPORT_LINES) + await expect(importCard(p).locator('textarea')).toHaveValue(IMPORT_LINES, { timeout: 20_000 }) + await settle(p) + }, + }, + // Clicking it would open the operating system's file chooser, which is + // not part of the picture; the button is shown, not pressed. + only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT' })), + { + target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }), + act: async p => { + await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click() + await expect(p.getByText('Import Packing List', { exact: true })).toHaveCount(0, { timeout: 30_000 }) + await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)), + }, + 'share-packing-item': { + guide: guide('share-packing-item'), + start: openLists, + steps: [ + { + target: p => p.getByRole('button', { name: /^My list/ }), + act: async p => { + await p.getByRole('button', { name: /^My list/ }).click() + await expect(p.getByText('No items match this filter')).toBeVisible({ timeout: 20_000 }) + await p.getByRole('button', { name: /^Shared/ }).click() + await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { await item(p, SHARED_ITEM).hover() }, + target: p => item(p, SHARED_ITEM).getByRole('button', { name: 'Sharing' }), + act: async p => { + await item(p, SHARED_ITEM).getByRole('button', { name: 'Sharing' }).click() + await expect(shareMenu(p)).toBeVisible() + await settle(p) + }, + }, + { + target: shareMenu, + act: async p => { + await shareMenu(p).getByRole('button', { name: /^Personal/ }).click() + await expect(item(p, SHARED_ITEM)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => p.getByRole('button', { name: /^My list/ }), + act: async p => { + await p.getByRole('button', { name: /^My list/ }).click() + await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await item(p, SHARED_ITEM).hover() + await item(p, SHARED_ITEM).getByRole('button', { name: 'Sharing' }).click() + await expect(shareMenu(p)).toBeVisible() + await beat(p, 300) + }, + target: p => shareMenu(p).getByRole('button', { name: MEMBER }), + act: async p => { + await shareMenu(p).getByRole('button', { name: MEMBER }).click() + // Ticking a name leaves the menu open on purpose; its catcher closes it. + await shareOverlay(p).click({ position: { x: 5, y: 5 } }) + await expect(item(p, SHARED_ITEM).getByText('shared with 1')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + const { tripId } = seededTrip() + const row = (await packingItems(p)).find(x => x.name === SHARED_ITEM) + if (row) { + await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } }) + } + }, + }, + 'packing-bags': { + guide: guide('packing-bags'), + start: openLists, + steps: [ + { + // The weight field's placeholder really is a dash: it is the app's own + // string, not prose, so it is matched exactly as rendered. + target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'), + act: async p => { + const field = item(p, BAG_ITEM).getByPlaceholder('\u2014') + await typeInto(p, field, '620') + await expect(field).toHaveValue('620', { timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { await item(p, BAG_ITEM).hover() }, + // The circle is dashed with a parcel in it while the item is in no bag. + target: p => item(p, BAG_ITEM).locator('button:has(svg.lucide-package)'), + act: async p => { + await item(p, BAG_ITEM).locator('button:has(svg.lucide-package)').click() + await expect(item(p, BAG_ITEM).getByRole('button', { name: 'Add bag' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => item(p, BAG_ITEM).getByRole('button', { name: 'Add bag' }), + act: async p => { + await item(p, BAG_ITEM).getByRole('button', { name: 'Add bag' }).click() + const field = item(p, BAG_ITEM).getByPlaceholder('Bag name...') + await typeInto(p, field, BAG) + await field.press('Enter') + await expect(bagSidebar(p)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + only(bagSidebar), + { + target: p => bagSidebar(p).getByRole('button', { name: 'Set limit' }), + act: async p => { + await bagSidebar(p).getByRole('button', { name: 'Set limit' }).click() + // Entered in kilograms, stored in grams. + const field = bagSidebar(p).locator('input[aria-label="Weight limit"]') + await typeInto(p, field, '8') + await field.press('Enter') + await expect(bagSidebar(p).getByText('/ 8.0 kg')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + // The card's member picker; the column's other plus is Add bag, below it. + target: p => bagSidebar(p).locator('button:has(svg.lucide-plus)').first(), + act: async p => { + await bagSidebar(p).locator('button:has(svg.lucide-plus)').first().click() + await bagSidebar(p).getByRole('button', { name: MEMBER }).click() + await bagSidebar(p).getByRole('button', { name: 'Close' }).click() + await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + const { tripId } = seededTrip() + const res = await p.request.get(`/api/trips/${tripId}/packing/bags`) + const body = (await res.json()) as { bags?: { id: number; name: string }[] } + for (const bag of (body.bags ?? []).filter(x => x.name === BAG)) { + // bag_id is ON DELETE SET NULL, so the item is freed with it. + await p.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`) + } + const row = (await packingItems(p)).find(x => x.name === BAG_ITEM) + if (row) await p.request.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { weight_grams: null } }) + }, + }, + 'create-todo': { + guide: guide('create-todo'), + start: openTodos, + steps: [ + { + target: p => p.getByRole('button', { name: 'Add new task' }), + act: async p => { + await p.getByRole('button', { name: 'Add new task' }).click() + await expect(modal(p).getByPlaceholder('Task name')).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await typeInto(p, modal(p).getByPlaceholder('Task name'), NEW_TASK) + await typeInto(p, modal(p).getByPlaceholder('Description (optional)'), NEW_TASK_NOTE) + await settle(p) + }, + target: p => modal(p).getByPlaceholder('Task name'), + }, + { + target: p => modal(p).getByRole('button', { name: 'No list' }), + act: async p => { + await modal(p).getByRole('button', { name: 'No list' }).click() + // The select's list is portalled to , outside the dialog. + await p.getByRole('button', { name: TASK_LIST, exact: true }).click() + await expect(modal(p).getByRole('button', { name: TASK_LIST })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('button', { name: 'P1', exact: true }).locator('xpath=..'), + act: async p => { + await modal(p).getByRole('button', { name: 'P2', exact: true }).click() + await settle(p) + }, + }, + { + prepare: async p => { + // Exact: the keyboard button beside it is "Enter date manually", which a + // loose name match also finds. + await modal(p).getByRole('button', { name: 'Date', exact: true }).click() + await expect(p.getByRole('button', { name: DUE_DAY })).toBeVisible() + await beat(p, 300) + }, + target: p => p.getByRole('dialog').last(), + act: async p => { + await p.getByRole('button', { name: DUE_DAY }).click() + await modal(p).getByRole('button', { name: 'Unassigned' }).click() + await p.getByRole('button', { name: ASSIGNEE }).click() + await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('button', { name: 'Create task' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Create task' }).click() + await expect(p.locator('.trek-modal-backdrop')).toHaveCount(0, { timeout: 30_000 }) + await expect(task(p, NEW_TASK)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteTodo(p, NEW_TASK), + }, + 'todo-filters': { + guide: guide('todo-filters'), + start: openTodos, + steps: [ + { + target: p => sidebarFilter(p, 'Overdue'), + act: async p => { + await sidebarFilter(p, 'Overdue').click() + await expect(p.getByRole('heading', { name: 'Overdue' })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => sidebarFilter(p, OTHER_TASK_LIST), + act: async p => { + await sidebarFilter(p, OTHER_TASK_LIST).click() + await expect(p.getByRole('heading', { name: OTHER_TASK_LIST })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await sidebarFilter(p, 'All').click() + await settle(p) + }, + target: p => p.getByRole('button', { name: 'Priority', exact: true }), + act: async p => { + await p.getByRole('button', { name: 'Priority', exact: true }).click() + await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(EDITED_TASK, { timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => task(p, EDITED_TASK), + act: async p => { + await task(p, EDITED_TASK).click() + await expect(todoPane(p)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await todoPane(p).getByRole('button', { name: 'Unassigned' }).click() + await p.getByRole('button', { name: ASSIGNEE }).click() + await settle(p) + }, + target: p => todoPane(p).getByRole('button', { name: 'Save changes' }), + act: async p => { + await todoPane(p).getByRole('button', { name: 'Save changes' }).click() + await expect(task(p, EDITED_TASK)).toContainText(ASSIGNEE, { timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => unassignTodo(p, EDITED_TASK), + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureListsFixtures(request) +}) + +test('every registered lists guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripListsContext.guides].sort()) +}) + +test('hero: trip-lists', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripListsContext.id, openLists) +}) + +for (const id of tripListsContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-map.guide.ts b/client/e2e/help/trip-map.guide.ts new file mode 100644 index 000000000..311e40825 --- /dev/null +++ b/client/e2e/help/trip-map.guide.ts @@ -0,0 +1,481 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureMapFixtures } from './fixtures' +import { openTrip, openTripOnDay, selectDay, modal } from './trip-shared' +import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the map in the middle of a trip's plan, keyed by the ids in + * `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan" + * with the Tokyo to Kyoto train added up front, so the bulk booking-route + * toggle has a second route to draw inside the frame; what a guide changes it + * puts back in `cleanup`, so the next one starts the same. + */ + +const guide = (id: string): HelpGuide => { + const g = tripMapGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** The Leaflet map: the whole middle of the plan, with both columns floating over it. */ +const map = (page: Page) => page.locator('#trek-map') +/** A cluster bubble. Its only text is the number of places folded into it. */ +const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom') +/** + * A place pin. Cluster bubbles and the explore results are markers too, so both + * are excluded: a bubble holds `.marker-cluster-custom`, and only a result pin + * carries an `aria-label` (a place marker is a bare divIcon with no name at all, + * which is why a specific place can only be found through its order badge). + */ +const pins = (page: Page) => + page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])') +/** The pin of the stop that is number `n` in the open day. */ +const badged = (page: Page, n: number) => pins(page).filter({ hasText: new RegExp(`^\\s*${n}\\s*$`) }).first() +/** An explore result: the one kind of marker with an accessible name. */ +const poiPins = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[aria-label]') +/** The end of a drawn booking route. */ +const endpoints = (page: Page) => page.locator('#trek-map .trek-endpoint-marker') +/** The card that follows the pointer over a pin. */ +const hoverCard = (page: Page) => page.getByTestId('tooltip') +/** + * A category of the explore pill. The phone portal renders a second copy of the + * whole pill, hidden at this width, so this goes by role rather than by + * `aria-label`, which would match both. + */ +const category = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true }) +/** The pill itself: the nearest block holding the first category and the last. */ +const categoryRow = (page: Page) => + category(page, 'Restaurants').locator('xpath=ancestor::div[.//button[@aria-label="Activities"]][1]') +/** The route icon on a booking's row, and the one in the toolbar above the days. */ +const connection = (page: Page, shown: boolean) => + page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} booking routes"]`) +const allConnections = (page: Page, shown: boolean) => + page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} all booking routes"]`) +const overviewPanel = (page: Page) => page.getByTestId('trip-overview-panel') +/** The sheet a booking's endpoint opens. Its own portal, and the only Close on the plan. */ +const sheetClose = (page: Page) => page.getByRole('button', { name: 'Close', exact: true }) +/** A row of the places column. */ +const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first() +/** + * The card that opens under the map when a place is selected. By its own test + * id, not by a button in it: the collection button reads Save to Collection or + * Saved depending on whether that place is already in a list, and the seed puts + * the three Kyoto places in one, so a locator on the label matches for some + * pins and for others not. + */ +const inspector = (page: Page) => page.getByTestId('inspector-scroll') +/** The details panel's collapse: it keeps the day selected, where its close would drop it. */ +const collapseDayDetails = (page: Page) => page.locator('button:has(svg.lucide-chevrons-down)') + +/** + * A spot on the map with nothing on it: right of the days column, left of the + * explore pill, and clear of everything the plan floats over the lower half of + * the map. Map-relative, so it is passed as a click `position`. + */ +const EMPTY = { x: 470, y: 80 } + +/** + * The first marker of a set the pointer can actually reach. Leaflet stacks + * markers that overlap and the two columns float over the edges of the map, so + * `.first()` is regularly one that nothing can hover, let alone click. Explore + * results are searched from the end because Leaflet draws the southernmost + * marker on top. + */ +async function reachable(locator: Locator, fromEnd = false): Promise { + const total = await locator.count() + for (let i = 0; i < total; i++) { + const index = fromEnd ? total - 1 - i : i + try { + await locator.nth(index).hover({ trial: true, timeout: 1500 }) + return index + } catch { + // Covered by another marker or by one of the floating columns. + } + } + throw new Error(`no reachable marker among ${total}`) +} + +/** + * Move the map, so "Search this area" appears. Leaflet's own keyboard panning + * rather than a mouse drag: a drag has to start on bare map, and after a search + * there are sixty result pins that each swallow the press it starts with. + */ +async function panMap(page: Page): Promise { + await map(page).focus() + for (let i = 0; i < 4; i++) { + await page.keyboard.press('ArrowLeft') + await page.waitForTimeout(250) + } + for (let i = 0; i < 2; i++) { + await page.keyboard.press('ArrowUp') + await page.waitForTimeout(250) + } + await settle(page) +} + +/** + * Open the trip framed on day 1's two Tokyo stops. + * + * The frame is what the three guides starting here have in common: one + * right-clicks a fixed point on the map and needs a street under it, one asks + * the map what it can see for cafes, one puts imagery under the pins. The + * whole-trip fit spans Japan, where that fixed point is the Sea of Japan as + * often as it is a street and the address look-up comes back empty, and zooming + * into whichever cluster the pointer reaches first lands on Tokyo or on Kyoto by + * chance. Day 1 is the one frame the seed guarantees is over a city. + * + * The details panel is folded rather than closed: closing it deselects the day + * (`onClose` in `TripPlannerPage` clears both) and the map goes straight back to + * the whole trip. + */ +async function openOnTokyo(page: Page): Promise { + await openTripOnDay(page, 1) + await collapseDayDetails(page).last().click() + await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 }) + await settle(page) +} + +async function deleteByName(page: Page, ...names: string[]): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/places`) + const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + const places = Array.isArray(body) ? body : (body.places ?? []) + for (const place of places.filter(x => names.includes(x.name))) { + await page.request.delete(`/api/trips/${tripId}/places/${place.id}`) + } +} + +/** + * The markers the running guide works on. Which one is reachable is decided + * once, by `reachable`, and the steps that follow reuse the answer; `pin` holds + * a place pin, an explore result or a route endpoint, whichever the guide is + * about. `found` is the name of the explore result a guide took into the trip, + * so `cleanup` can take exactly that one out again. + */ +let bubble = 0 +let pin = 0 +let found = '' + +const NEW_PLACE = 'Lunch stop' + +const SCRIPTS: Record = { + 'map-markers': { + guide: guide('map-markers'), + start: async p => { + await openTrip(p) + await expect(clusters(p).first()).toBeVisible({ timeout: 30_000 }) + await settle(p) + bubble = await reachable(clusters(p)) + }, + steps: [ + { + target: p => clusters(p).nth(bubble), + act: async p => { + await clusters(p).nth(bubble).click() + await expect.poll(() => pins(p).count(), { timeout: 20_000 }).toBeGreaterThan(2) + await settle(p) + pin = await reachable(pins(p)) + }, + }, + { + // captureGuide hovers the target itself; this only makes sure the card + // is up before the shot rather than a frame after it. + prepare: async p => { + await pins(p).nth(pin).hover() + await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 }) + }, + target: p => pins(p).nth(pin), + }, + { + // The picture worth having is the panel the click opens, not the pin + // again, so the click is the preparation and the panel is the target. + prepare: async p => { + await pins(p).nth(pin).click() + await expect(inspector(p)).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + target: p => inspector(p).locator('xpath=..'), + act: async p => { + await map(p).click({ position: EMPTY }) + await expect(inspector(p)).toHaveCount(0) + await settle(p) + }, + }, + { + prepare: async p => { + await selectDay(p, 1) + // The panel IS the selection, so it is folded away rather than closed: + // closing it would take the numbers off the pins again. + await collapseDayDetails(p).last().click() + await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + target: p => badged(p, 1), + }, + { + // Picture only, like the other drags in this family: HTML5 drag is what + // the map hands the day plan, and a synthesised one does not start it. + prepare: async p => { + pin = await reachable(pins(p)) + }, + target: p => pins(p).nth(pin), + dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'), + }, + ], + }, + 'map-nearby-places': { + guide: guide('map-nearby-places'), + // A search covers what is on screen, and day 1's fit is the two Tokyo stops + // with the city between them: enough of a district for the sixty hits to + // spread over the frame instead of piling up in the middle of it. + start: openOnTokyo, + steps: [ + { target: categoryRow }, + { + target: p => category(p, 'Cafés'), + act: async p => { + await category(p, 'Cafés').click() + await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await panMap(p) + await expect(p.getByRole('button', { name: 'Search this area' })).toBeVisible({ timeout: 15_000 }) + }, + target: p => p.getByRole('button', { name: 'Search this area' }), + act: async p => { + await p.getByRole('button', { name: 'Search this area' }).click() + await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + pin = await reachable(poiPins(p), true) + }, + target: p => poiPins(p).nth(pin), + act: async p => { + await poiPins(p).nth(pin).click() + const name = modal(p).getByPlaceholder('e.g. Eiffel Tower') + await expect(name).not.toHaveValue('', { timeout: 20_000 }) + // Which places OpenStreetMap answers with is never the same twice, so + // the name is read off the form rather than assumed, and `cleanup` + // deletes exactly what was created. + found = await name.inputValue() + await settle(p) + }, + }, + { target: p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]') }, + { + target: p => modal(p).getByRole('button', { name: 'Add', exact: true }), + act: async p => { + await modal(p).getByRole('button', { name: 'Add', exact: true }).click() + await expect(modal(p)).toHaveCount(0) + await expect(row(p, found)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteByName(p, found), + }, + 'map-add-place': { + guide: guide('map-add-place'), + start: openOnTokyo, + steps: [ + { + target: map, + // The map fills the frame, so the ring would sit nowhere in particular: + // park the pointer on the spot the right-click is about to land on. + hover: async p => { + const box = await map(p).boundingBox() + if (!box) throw new Error('the map has no box') + await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y) + }, + act: async p => { + await map(p).click({ button: 'right', position: EMPTY }) + await expect(modal(p).getByText('Add Place/Activity')).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + // The address is a look-up of the coordinates and lands after the form. + await expect(modal(p).getByPlaceholder('Latitude (e.g. 48.8566)')).not.toHaveValue('') + await expect(modal(p).getByPlaceholder('Street, City, Country')).not.toHaveValue('', { timeout: 30_000 }) + await settle(p) + }, + target: p => modal(p).getByPlaceholder('Street, City, Country').locator('xpath=ancestor::div[1]'), + }, + { + prepare: async p => { + await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE) + await settle(p) + }, + target: p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]'), + }, + { + target: p => modal(p).getByRole('button', { name: 'Add', exact: true }), + act: async p => { + await modal(p).getByRole('button', { name: 'Add', exact: true }).click() + await expect(modal(p)).toHaveCount(0) + await expect(row(p, NEW_PLACE)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteByName(p, NEW_PLACE), + }, + 'map-satellite': { + guide: guide('map-satellite'), + start: async p => { + await openOnTokyo(p) + pin = await reachable(pins(p)) + }, + steps: [ + { + target: p => p.getByRole('button', { name: 'Switch to satellite view' }), + act: async p => { + await p.getByRole('button', { name: 'Switch to satellite view' }).click() + await expect(p.getByRole('button', { name: 'Switch to map view' })).toBeVisible() + // The imagery is raster tiles from a third party; give them a moment + // or the picture catches the grey underneath. + await expect(p.locator('#trek-map img.leaflet-tile').first()).toBeVisible({ timeout: 30_000 }) + await settle(p) + await p.waitForTimeout(1500) + }, + }, + { target: p => pins(p).nth(pin) }, + { + target: p => p.getByRole('button', { name: 'Switch to map view' }), + act: async p => { + await p.getByRole('button', { name: 'Switch to map view' }).click() + await expect(p.getByRole('button', { name: 'Switch to satellite view' })).toBeVisible() + await settle(p) + }, + }, + ], + // The layer is kept on the account, not on the trip: left on satellite it + // would be the basemap of every picture taken after this guide. + cleanup: async p => { + await p.request.post('/api/settings/bulk', { data: { settings: { map_base_layer: 'default' } } }) + }, + }, + 'map-whole-trip': { + guide: guide('map-whole-trip'), + start: p => openTrip(p), + steps: [ + { + target: p => p.getByTestId('trip-overview-pill'), + act: async p => { + await p.getByTestId('trip-overview-pill').click() + // One router request per leg, paced, so the card takes its time. + await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 }) + await settle(p) + }, + }, + { + // The total carries an … until every leg has answered; a picture taken + // before that shows a partial sum. + prepare: async p => { + await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 }) + await settle(p) + }, + target: overviewPanel, + }, + { + target: p => overviewPanel(p).getByRole('button').first(), + act: async p => { + await overviewPanel(p).getByRole('button').first().click() + await settle(p) + }, + }, + // No act: the guide ends with the whole trip on the map, which is what + // the result picture is of. `cleanup` presses the button. + { target: p => p.getByRole('button', { name: 'Hide whole trip' }) }, + ], + cleanup: async p => { + const off = p.getByRole('button', { name: 'Hide whole trip' }) + if (await off.isVisible().catch(() => false)) await off.click() + }, + }, + 'map-booking-routes': { + guide: guide('map-booking-routes'), + start: p => openTrip(p), + steps: [ + { target: p => connection(p, false).first() }, + { + prepare: async p => { + await connection(p, false).first().click() + await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(1) + await settle(p) + pin = await reachable(endpoints(p)) + }, + target: p => endpoints(p).nth(pin), + act: async p => { + await endpoints(p).nth(pin).click() + await expect(sheetClose(p)).toBeVisible({ timeout: 15_000 }) + await settle(p) + }, + }, + { + target: p => sheetClose(p).locator('xpath=ancestor::div[2]'), + // The booking sheet is its own portal and ignores Escape. + act: async p => { + await sheetClose(p).click() + await expect(sheetClose(p)).toHaveCount(0) + await settle(p) + }, + }, + { + target: p => allConnections(p, false), + act: async p => { + await allConnections(p, false).click() + await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(2) + await settle(p) + }, + }, + { + target: p => allConnections(p, true), + act: async p => { + await allConnections(p, true).click() + await expect(endpoints(p)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + ], + // The per-trip choice lives in this browser, not on the server, so it would + // otherwise carry into every guide that runs after it in the same context. + cleanup: async p => { + const { tripId } = seededTrip() + await p.evaluate(id => localStorage.removeItem(`trek:visible-connections:${id}`), tripId) + }, + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureMapFixtures(request) +}) + +test('every registered map guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripMapContext.guides].sort()) +}) + +test('hero: trip-map', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripMapContext.id, p => openTrip(p)) +}) + +for (const id of tripMapContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-place.guide.ts b/client/e2e/help/trip-place.guide.ts new file mode 100644 index 000000000..47fb2574b --- /dev/null +++ b/client/e2e/help/trip-place.guide.ts @@ -0,0 +1,494 @@ +import { test, expect, type Page, type Locator } from '@playwright/test' +import { captureGuide, captureHero, beat, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, coverFixture, ensureTrack, ensureDaysFixtures, ensurePlaceFixtures, PLACE_WEBSITE, RATED_PLACE } from './fixtures' +import { openTrip, selectDay, modal, dialog } from './trip-shared' +import { tripPlaceContext, tripPlaceGuides } from '../../src/help/contexts/tripPlace' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the place card that floats over the map, keyed by the ids in + * `src/help/contexts/tripPlace.ts`. They run on the seeded "Autumn in Japan" + * with the Kyoto walk imported, the day fixtures in place and this screen's own + * fixtures on top; what a guide changes it changes back in `cleanup`, so the + * next one starts from the same card. + */ + +const guide = (id: string): HelpGuide => { + const g = tripPlaceGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** An unplanned waypoint of the imported walk: the place that is on no day yet. */ +const SPARE = 'Ginkaku-ji' +/** The walk itself: the only place of the trip that carries a path. */ +const TRACK = "Philosopher's Path" +/** What the GPX import hands the trip's first track: TRACK_COLORS[0]. */ +const TRACK_IMPORT_COLOR = '#1d4ed8' +/** The colour the guide picks instead: TRACK_COLORS[1]. */ +const TRACK_PICKED_COLOR = '#ea580c' +/** The booking `ensureDaysFixtures` pins to the market stop on day 6. */ +const BOOKING = 'Lunch at Nishiki' +/** The only list the seed creates. */ +const LIST = 'Kyoto shortlist' +const UPLOADED = 'cover-fixture.jpg' + +/** The card itself: the scroll body's parent holds head, body and footer. */ +const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..') +const head = (page: Page) => card(page).locator('> div').first() +const footer = (page: Page) => card(page).locator('div.border-t') +/** A row of the places column (the rows are options of a list). */ +const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() +/** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */ +const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first() +/** PlaceRating's root: the stars, the average and the voter faces on one line. */ +const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..') +/** ParticipantsBox: found by its eyebrow, which is the box's first child. */ +const participants = (page: Page) => card(page).getByText('Participants', { exact: true }).locator('xpath=..') +/** A member's chip, or, once the chip is gone, that member's line in the add list. */ +const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) }) +const addMember = (page: Page) => participants(page).getByRole('button', { name: '+', exact: true }) +/** "Files" while the place has none, " files" once it has. */ +const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ }) +/** The booking strip is a button only for a viewer who may open the booking. */ +const bookingStrip = (page: Page) => card(page).getByRole('button', { name: 'Edit Reservation' }) +/** The strip's lower row: Date, Time and Booking Code beside each other. */ +const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1) +const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }) +const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR }) + +async function openPlace(page: Page, name: string): Promise { + await row(page, name).click() + await expect(card(page)).toBeVisible() + await settle(page) +} + +/** Opening the card from a stop is what gives it the stop's participants and booking. */ +async function openStop(page: Page, name: string): Promise { + await stop(page, name).click() + await expect(card(page)).toBeVisible() + await settle(page) +} + +// ── The seed over the API, for the cleanups ────────────────────────────────── + +type ApiPlace = { id: number; name: string; lat?: number | null; lng?: number | null } +type ApiAssignment = { id: number; place_id: number } + +async function tripPlaces(page: Page): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/places`) + const body = (await res.json()) as { places?: ApiPlace[] } | ApiPlace[] + return Array.isArray(body) ? body : (body.places ?? []) +} + +const findPlace = async (page: Page, name: string): Promise => + (await tripPlaces(page)).find(p => p.name === name) + +async function patchPlace(page: Page, name: string, data: Record): Promise { + const { tripId } = seededTrip() + const place = await findPlace(page, name) + if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data }) +} + +async function assignmentsOf(page: Page, dayIndex: number): Promise { + const { tripId, dayIds } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`) + const body = (await res.json()) as { assignments?: ApiAssignment[] } + return body.assignments ?? [] +} + +async function assignmentFor(page: Page, dayIndex: number, name: string): Promise { + const place = await findPlace(page, name) + if (!place) return undefined + return (await assignmentsOf(page, dayIndex)).find(a => a.place_id === place.id) +} + +const closeModal = async (page: Page): Promise => { + if (await modal(page).isVisible().catch(() => false)) { + await page.keyboard.press('Escape') + await expect(modal(page)).toHaveCount(0) + } +} + +const only = (target: (p: Page) => Locator) => ({ target }) + +const SCRIPTS: Record = { + 'read-place': { + guide: guide('read-place'), + // Day 1 explicitly: closing a day's detail panel clears the open day, and + // the card is a different card without one. + start: async p => { + await openTrip(p, { day: null }) + await selectDay(p, 1) + }, + steps: [ + { + target: p => stop(p, 'Senso-ji Temple'), + act: p => openStop(p, 'Senso-ji Temple'), + }, + only(head), + only(ratingRow), + only(p => card(p).locator('.collab-note-md').first()), + only(participants), + only(footer), + ], + }, + 'rate-place': { + guide: guide('rate-place'), + start: async p => { + await openTrip(p, { day: null }) + await openPlace(p, RATED_PLACE) + }, + steps: [ + only(ratingRow), + { + target: p => card(p).getByRole('radio', { name: '4', exact: true }), + act: async p => { + await card(p).getByRole('radio', { name: '4', exact: true }).click() + await expect(card(p).getByRole('radio', { name: '4', exact: true })) + .toHaveAttribute('aria-checked', 'true', { timeout: 20_000 }) + await settle(p) + }, + }, + { + // captureGuide hovers the target before it shoots, which is the gesture + // the step describes; the tooltip is a portal, so it is asserted here. + target: ratingRow, + act: async p => { await expect(p.getByRole('tooltip')).toBeVisible() }, + }, + only(p => row(p, RATED_PLACE)), + ], + cleanup: async p => { + const { tripId } = seededTrip() + const place = await findPlace(p, RATED_PLACE) + // Only the admin's vote: the two members' votes are the fixture's. + if (place) await p.request.delete(`/api/trips/${tripId}/places/${place.id}/rating`) + }, + }, + 'place-image': { + guide: guide('place-image'), + start: p => openTrip(p, { day: null }), + steps: [ + { + target: p => row(p, 'Arashiyama Bamboo Grove'), + act: p => openPlace(p, 'Arashiyama Bamboo Grove'), + }, + { + target: p => card(p).getByRole('button', { name: /^(Upload image|Change image)$/ }), + act: async p => { + await card(p).locator('input[accept*="image/jpeg"]').setInputFiles(await coverFixture()) + await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + only(head), + only(p => row(p, 'Arashiyama Bamboo Grove')), + ], + cleanup: p => patchPlace(p, 'Arashiyama Bamboo Grove', { image_url: null }), + }, + 'place-day-assign': { + guide: guide('place-day-assign'), + start: p => openTrip(p, { day: null }), + steps: [ + { + target: p => dayHeader(p, 6), + act: p => selectDay(p, 6), + }, + { + target: p => row(p, SPARE), + act: async p => { + await openPlace(p, SPARE) + await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible() + }, + }, + { + target: p => footer(p).getByRole('button', { name: 'Add to Day' }), + act: async p => { + await footer(p).getByRole('button', { name: 'Add to Day' }).click() + await expect(footer(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + // The gesture only: dragging it here would leave the day reordered for the + // last step's picture, and the arrow already says what to do. + { target: p => stop(p, SPARE), dropTo: p => stop(p, 'Arashiyama Bamboo Grove') }, + { + target: p => footer(p).getByRole('button', { name: 'Remove from Day' }), + act: async p => { + await footer(p).getByRole('button', { name: 'Remove from Day' }).click() + await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + const { tripId, dayIds } = seededTrip() + const assignment = await assignmentFor(p, 5, SPARE) + if (assignment) await p.request.delete(`/api/trips/${tripId}/days/${dayIds[5]}/assignments/${assignment.id}`) + }, + }, + 'place-participants': { + guide: guide('place-participants'), + start: async p => { + await openTrip(p, { day: null }) + await selectDay(p, 1) + }, + steps: [ + { + target: p => stop(p, 'teamLab Planets'), + act: async p => { + await openStop(p, 'teamLab Planets') + await expect(participants(p)).toBeVisible() + }, + }, + { + target: p => member(p, 'jonas'), + act: async p => { + await member(p, 'jonas').click() + await expect(member(p, 'jonas')).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await addMember(p).click() + await expect(member(p, 'jonas')).toBeVisible() + await beat(p, 300) + }, + target: participants, + }, + { + target: p => member(p, 'jonas'), + act: async p => { + await member(p, 'jonas').click() + await expect(addMember(p)).toHaveCount(0, { timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + const { tripId } = seededTrip() + const assignment = await assignmentFor(p, 0, 'teamLab Planets') + // An empty list is what "everybody is going" is stored as. + if (assignment) { + await p.request.put(`/api/trips/${tripId}/assignments/${assignment.id}/participants`, { data: { user_ids: [] } }) + } + }, + }, + 'place-booking': { + guide: guide('place-booking'), + start: async p => { + await openTrip(p, { day: null }) + await selectDay(p, 6) + }, + steps: [ + { + target: p => stop(p, 'Nishiki Market'), + act: async p => { + await openStop(p, 'Nishiki Market') + await expect(card(p).getByText(BOOKING)).toBeVisible() + }, + }, + // The fields rather than the whole strip: step 3 rings the strip, and two + // steps pointing at one element give the reader the same picture twice. + only(bookingFields), + { + target: bookingStrip, + act: async p => { + await bookingStrip(p).click() + await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => modal(p).getByText('Link to day assignment', { exact: true }).locator('xpath=..'), + act: closeModal, + }, + { + // The + is rendered only while its own stop is hovered, so the row is + // put in view and hovered before the target is looked up, and hovered + // again for the picture instead of the button itself. + prepare: async p => { + await stop(p, 'Nishiki Market').scrollIntoViewIfNeeded() + await stop(p, 'Nishiki Market').hover() + await expect(stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' })).toBeVisible() + }, + target: p => stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }), + hover: p => stop(p, 'Nishiki Market').hover(), + act: async p => { + await stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }).click() + await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible() + await settle(p) + }, + }, + ], + // The new booking is never saved, so there is nothing to delete. + cleanup: closeModal, + }, + 'place-files': { + guide: guide('place-files'), + start: async p => { + await openTrip(p, { day: null }) + await openPlace(p, 'teamLab Planets') + }, + steps: [ + only(filesToggle), + { + target: p => card(p).getByText('Upload', { exact: true }), + act: async p => { + await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture()) + await expect(card(p).getByRole('button', { name: '1 files' })).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + // The list opens itself once the upload lands. + only(p => card(p).getByRole('button', { name: '1 files' })), + // No click: openFile hands the browser a download. + only(p => card(p).getByRole('button', { name: UPLOADED })), + ], + cleanup: async p => { + const { tripId } = seededTrip() + const res = await p.request.get(`/api/trips/${tripId}/files`) + const body = (await res.json()) as { files?: { id: number; original_name: string }[] } + for (const file of (body.files ?? []).filter(f => f.original_name === UPLOADED)) { + await p.request.delete(`/api/trips/${tripId}/files/${file.id}`) + await p.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`) + } + }, + }, + 'place-navigation': { + guide: guide('place-navigation'), + start: async p => { + await openTrip(p, { day: null }) + await openPlace(p, PLACE_WEBSITE.place) + }, + steps: [ + { + target: p => footer(p).getByRole('button', { name: 'Navigation' }), + act: async p => { + await footer(p).getByRole('button', { name: 'Navigation' }).click() + await expect(p.getByRole('menu')).toBeVisible() + await settle(p) + }, + }, + only(p => p.getByRole('menu')), + { + // Clicking one opens an external tab, so the menu is closed instead. + target: p => p.getByRole('menuitem', { name: 'Google Maps' }), + act: async p => { + await p.keyboard.press('Escape') + await expect(p.getByRole('menu')).toHaveCount(0) + }, + }, + only(p => footer(p).getByRole('button', { name: 'Open Website' })), + ], + }, + 'place-to-collection': { + guide: guide('place-to-collection'), + start: async p => { + await openTrip(p, { day: null }) + await openPlace(p, 'Senso-ji Temple') + }, + steps: [ + { + target: p => footer(p).getByRole('button', { name: 'Save to Collection' }), + act: async p => { + await footer(p).getByRole('button', { name: 'Save to Collection' }).click() + await expect(modal(p).getByRole('heading', { name: 'Save to list' })).toBeVisible() + await settle(p) + }, + }, + only(dialog), + { + target: p => modal(p).getByRole('button', { name: LIST }), + act: async p => { + await modal(p).getByRole('button', { name: LIST }).click() + await expect(modal(p).getByRole('button', { name: LIST })).toHaveClass(/border-accent/, { timeout: 20_000 }) + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('button', { name: 'Close' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Close' }).click() + await expect(modal(p)).toHaveCount(0) + await expect(footer(p).getByRole('button', { name: 'Saved' })).toBeVisible({ timeout: 20_000 }) + }, + }, + ], + cleanup: async p => { + const place = await findPlace(p, 'Senso-ji Temple') + if (!place) return + const query = new URLSearchParams({ name: place.name, lat: String(place.lat ?? ''), lng: String(place.lng ?? '') }) + const res = await p.request.get(`/api/addons/collections/membership?${query.toString()}`) + const body = (await res.json()) as { lists?: { place_id: number }[] } + for (const entry of body.lists ?? []) { + await p.request.delete(`/api/addons/collections/places/${entry.place_id}`) + } + }, + }, + 'place-track': { + guide: guide('place-track'), + start: p => openTrip(p, { day: null }), + steps: [ + { + target: p => row(p, TRACK), + act: async p => { + await openPlace(p, TRACK) + await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible() + }, + }, + only(p => card(p).getByText('Track Stats', { exact: true }).locator('xpath=ancestor::div[2]')), + { + target: p => card(p).getByRole('button', { name: 'Track color' }), + act: async p => { + await card(p).getByRole('button', { name: 'Track color' }).click() + await expect(swatch(p)).toBeVisible() + await settle(p) + }, + }, + { + target: swatch, + act: async p => { + await swatch(p).click() + await expect(swatch(p)).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 }) + await settle(p) + }, + }, + // No click: it would undo the result picture. The reset happens in cleanup. + only(p => card(p).getByRole('button', { name: 'Automatic color' })), + ], + cleanup: p => patchPlace(p, TRACK, { route_color: TRACK_IMPORT_COLOR }), + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureTrack(request, seededTrip().tripId, 'kyoto-walk') + await ensureDaysFixtures(request) + await ensurePlaceFixtures(request) +}) + +test('every registered place guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlaceContext.guides].sort()) +}) + +test('hero: trip-place', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripPlaceContext.id, async p => { + await openTrip(p, { day: null }) + await selectDay(p, 1) + await openStop(p, 'Senso-ji Temple') + }) +}) + +for (const id of tripPlaceContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-roadtrip.guide.ts b/client/e2e/help/trip-roadtrip.guide.ts new file mode 100644 index 000000000..54f55faaa --- /dev/null +++ b/client/e2e/help/trip-roadtrip.guide.ts @@ -0,0 +1,697 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureRoadtripFixtures } from './fixtures' +import { openTrip, modal, dialog } from './trip-shared' +import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for road trip mode, keyed by the ids in + * `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in + * Japan" with the Road trip addon switched on, one long drive out of Tokyo on + * day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a + * guide writes it takes away again in `cleanup`, and `test.afterAll` switches + * the addon off again: it is an instance-wide setting, and while it is on + * every trip's left column grows a switch the other screens never show. + */ + +const guide = (id: string): HelpGuide => { + const g = tripRoadtripGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +// ── Where things are ────────────────────────────────────────────────────────── + +const rail = (page: Page) => page.locator('.roadtrip-rail-scroll') +/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */ +const dayCard = (page: Page, day: number) => + rail(page) + .locator('> section') + .filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) }) + .first() +/** + * One stop's row and everything hanging off it. The kind disc sits inside the + * row button while the stop is a destination and beside it once it is a stop on + * the way, so the list item is the only handle that holds for both. + */ +const stopItem = (page: Page, name: string) => rail(page).getByRole('listitem').filter({ hasText: name }).first() +const stopRow = (page: Page, name: string) => stopItem(page, name).getByRole('button').filter({ hasText: name }).first() +const kindDialog = (page: Page) => page.getByRole('dialog', { name: 'Kind of stop' }) +/** The corridor panel: before a search its heading is the only stable thing in it. */ +const corridor = (page: Page) => page.getByRole('heading', { name: 'Along the route' }).locator('xpath=ancestor::div[2]') +const kindPicker = (page: Page) => corridor(page).locator('button[aria-haspopup="listbox"]') +const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Day \d/ }).first() +const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ }) +/** The alternatives bar floats over the map, inside neither column. */ +const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]') +/** The refuel band belongs to no stop: it runs across the rail between two of them. */ +const dryBand = (page: Page) => page.getByText('Tank runs out here').first().locator('xpath=ancestor::div[3]') +const addFuelStop = (page: Page) => dryBand(page).getByRole('button', { name: /as a fuel stop$/ }).first() +/** A via handle is the one marker Leaflet drags itself; the corridor pins use HTML5 drag. */ +const viaHandle = (page: Page) => page.locator('.leaflet-marker-icon.leaflet-marker-draggable').first() + +// ── Getting there ───────────────────────────────────────────────────────────── + +async function openRoadtrip(page: Page): Promise { + await openTrip(page, { day: null }) + await page.getByRole('tab', { name: 'Road trip' }).click() + await waitForTheDrive(page) +} + +/** + * Every leg of every day is a router round trip, and the rail says nothing at + * all until they land. The totals appear first and the partial line only while + * something is outstanding, so neither of them on its own says the drive is + * there; a drive band on a day that really has one does. + */ +async function waitForTheDrive(page: Page): Promise { + await expect(page.getByText('Driving time', { exact: true })).toBeVisible({ timeout: 120_000 }) + await expect(dayCard(page, 2).getByRole('button', { name: /Other ways$/ }).first()).toBeVisible({ timeout: 120_000 }) + await expect(page.getByText('Still working out the rest of the drive')).toHaveCount(0, { timeout: 120_000 }) + await settle(page) +} + +/** + * Put a leg where the pointer can reach it. The map opens fitted to the whole + * trip, which leaves the drawn route under the two floating panels; clicking a + * stop frames that stop instead, and the card it opens is closed again at once. + */ +async function frameOnStop(page: Page, name: string): Promise { + await stopRow(page, name).click() + await page.waitForTimeout(2500) + const close = page.locator('button:has(svg.lucide-x)').last() + if (await close.isVisible().catch(() => false)) await close.click() + await settle(page) +} + +type RoutePoint = { leg: number; x: number; y: number } + +/** + * Points that really lie on a drawn leg, with nothing over them. A bounding-box + * centre is no use here: a route is a curve, its box is mostly empty space, and + * what sits in the middle of it is usually somebody else's pin. + */ +async function routePoints(page: Page): Promise { + return page.evaluate(() => { + const paths = Array.from( + document.querySelectorAll('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]'), + ) + const found: { leg: number; x: number; y: number }[] = [] + paths.forEach((path, leg) => { + const length = path.getTotalLength() + const ctm = path.getScreenCTM() + if (!ctm || length < 24) return + for (let f = 0.08; f <= 0.92; f += 0.02) { + const at = path.getPointAtLength(length * f) + const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e) + const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f) + // The two panels float over the map; a point under one of them is not clickable. + if (x < 430 || x > 1550 || y < 220 || y > 950) continue + if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y }) + } + }) + return found + }) +} + +/** Which leg the via guide aims at, and where; worked out once, used by three steps. */ +let aimedLeg = 0 +let aimedAt = { x: 0, y: 0 } +/** The day the via landed on, read back between two steps of the same guide. */ +let viaDayId = 0 +/** Names of what a guide created, read back by its cleanup. */ +let addedStop = '' +let refuelStop = '' + +/** The leg with the most reachable points: the one worth clicking and worth a picture. */ +async function aimAtLeg(page: Page): Promise { + const points = await routePoints(page) + if (points.length === 0) throw new Error('no drawn leg is reachable on the map') + const counts = new Map() + for (const point of points) counts.set(point.leg, (counts.get(point.leg) ?? 0) + 1) + const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] + aimedLeg = best ? best[0] : points[0].leg + const own = points.filter(point => point.leg === aimedLeg) + const middle = own[Math.floor(own.length / 2)] ?? points[0] + aimedAt = { x: middle.x, y: middle.y } +} + +const aimedLine = (page: Page) => page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').nth(aimedLeg) +/** The pointer goes on the line, not on the centre of the box around it. */ +const hoverAimedLine = async (page: Page): Promise => { + await page.mouse.move(aimedAt.x, aimedAt.y) +} + +// ── What the guides write, and how it is taken back ─────────────────────────── + +type ViaRow = { id: number; day_id: number; lat: number; lng: number } + +async function listVias(page: Page): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`) + const body = (await res.json()) as { vias?: ViaRow[] } + return body.vias ?? [] +} + +async function clearVias(page: Page): Promise { + const { tripId } = seededTrip() + for (const via of await listVias(page)) { + await page.request.delete(`/api/trips/${tripId}/roadtrip/days/${via.day_id}/vias/${via.id}`) + } +} + +async function clearDayBoundaries(page: Page): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/roadtrip/day-boundaries`) + const body = (await res.json()) as { boundaries?: { day_number: number }[] } + for (const boundary of body.boundaries ?? []) { + await page.request.delete(`/api/trips/${tripId}/roadtrip/day-boundaries/${boundary.day_number}`) + } +} + +async function listPlaces(page: Page): Promise<{ id: number; name: string }[]> { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/places`) + const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + return Array.isArray(body) ? body : (body.places ?? []) +} + +async function deleteByName(page: Page, ...names: string[]): Promise { + const { tripId } = seededTrip() + const wanted = names.filter(name => name.length > 0) + if (wanted.length === 0) return + for (const place of (await listPlaces(page)).filter(x => wanted.includes(x.name))) { + await page.request.delete(`/api/trips/${tripId}/places/${place.id}`) + } +} + +/** Put one place back the way the seed had it. */ +async function restorePlace(page: Page, name: string, data: Record): Promise { + const { tripId } = seededTrip() + const place = (await listPlaces(page)).find(x => x.name === name) + if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data }) +} + +const closeModal = async (page: Page): Promise => { + if (await modal(page).isVisible().catch(() => false)) { + await page.keyboard.press('Escape') + await expect(modal(page)).toHaveCount(0) + } +} + +const only = (target: (p: Page) => Locator) => ({ target }) + +const SCRIPTS: Record = { + 'roadtrip-mode': { + guide: guide('roadtrip-mode'), + start: p => openTrip(p, { day: null }), + steps: [ + { + target: p => p.getByRole('tablist', { name: 'Plan view' }), + act: async p => { + await p.getByRole('tab', { name: 'Road trip' }).click() + await waitForTheDrive(p) + }, + }, + only(p => p.getByText('Driving time', { exact: true }).locator('xpath=ancestor::header[1]')), + only(p => dayCard(p, 2).locator('header')), + only(p => dayCard(p, 2).getByRole('list').first()), + { + target: p => dayCard(p, 1).locator('header'), + act: async p => { + await dayCard(p, 1).locator('header').click() + await expect(dayCard(p, 1).locator('header')).toHaveAttribute('aria-expanded', 'false') + await settle(p) + // Folded is not the state to leave the trip in, and the result picture + // is of the whole drive rather than of one card shut. + await dayCard(p, 1).locator('header').click() + await expect(dayCard(p, 1).locator('header')).toHaveAttribute('aria-expanded', 'true') + await settle(p) + }, + }, + ], + }, + 'roadtrip-stops': { + guide: guide('roadtrip-stops'), + start: openRoadtrip, + steps: [ + { + target: p => stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Make it a stop on the way' }), + act: async p => { + await stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Make it a stop on the way' }).click() + await expect(kindDialog(p)).toBeVisible() + await settle(p) + }, + }, + { + target: kindDialog, + act: async p => { + await kindDialog(p).getByRole('button', { name: 'Rest area' }).click() + await expect(kindDialog(p)).toHaveCount(0) + await expect( + stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }), + ).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + only(p => dayCard(p, 2).locator('header')), + { + prepare: async p => { + await stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }).click() + await expect(kindDialog(p)).toBeVisible() + await settle(p) + }, + target: p => kindDialog(p).getByRole('button', { name: 'Back to a destination' }), + act: async p => { + await kindDialog(p).getByRole('button', { name: 'Back to a destination' }).click() + await expect(kindDialog(p)).toHaveCount(0) + await expect(stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Make it a stop on the way' })).toBeVisible({ + timeout: 20_000, + }) + await settle(p) + }, + }, + { + target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }), + act: async p => { + await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click() + await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible() + await settle(p) + }, + }, + { + target: dialog, + act: async p => { + await modal(p).getByRole('button', { name: '1 h', exact: true }).click() + await expect(modal(p).getByRole('slider', { name: 'Minutes' })).toHaveValue('60') + await modal(p).getByRole('button', { name: 'Save', exact: true }).click() + await expect(modal(p)).toHaveCount(0) + await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 }) + await restorePlace(p, 'Meiji Jingu', { stop_type: null }) + }, + }, + 'roadtrip-corridor': { + guide: guide('roadtrip-corridor'), + start: async p => { + addedStop = '' + await openRoadtrip(p) + }, + steps: [ + { + target: dayPicker, + act: async p => { + await dayPicker(p).click() + await p.getByRole('button', { name: 'Day 2', exact: true }).last().click() + await expect(dayPicker(p)).toContainText('Day 2') + await settle(p) + }, + }, + { + prepare: async p => { + await kindPicker(p).click() + await expect(corridor(p).locator('div[role="listbox"]')).toBeVisible() + await settle(p) + }, + target: p => corridor(p).locator('div[role="listbox"]'), + act: async p => { + await corridor(p).getByRole('option', { name: 'Food' }).click() + // The list closes on a second click of its own trigger, not on Escape. + await kindPicker(p).click() + await expect(kindPicker(p)).toContainText('Food') + await settle(p) + }, + }, + { + target: p => corridor(p).getByRole('button', { name: '5 km', exact: true }).locator('xpath=..'), + act: async p => { + // The step's sentence tells the reader to choose a width and then search, + // so the width is chosen here even though 5 km is what the panel opens on. + await corridor(p).getByRole('button', { name: '5 km', exact: true }).click() + await expect(corridor(p).getByRole('button', { name: '5 km', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await corridor(p).getByRole('button', { name: 'Search', exact: true }).click() + await expect(corridor(p).getByText(/on the way$/).first()).toBeVisible({ timeout: 180_000 }) + await settle(p) + }, + }, + only(p => corridor(p).getByRole('listitem').first()), + { + prepare: async p => { + const row = corridor(p).getByRole('listitem').first() + addedStop = (await row.innerText()).split('\n')[0] ?? '' + await row.hover() + await settle(p) + }, + target: p => corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }), + act: async p => { + await corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }).click() + await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible() + await modal(p).getByRole('button', { name: 'Add', exact: true }).click() + await expect(modal(p)).toHaveCount(0, { timeout: 30_000 }) + await expect + .poll(async () => (await listPlaces(p)).some(x => x.name === addedStop), { timeout: 30_000 }) + .toBe(true) + await settle(p) + }, + }, + ], + cleanup: p => deleteByName(p, addedStop), + }, + 'roadtrip-via': { + guide: guide('roadtrip-via'), + start: openRoadtrip, + steps: [ + { + target: p => stopItem(p, 'Meiji Jingu'), + act: async p => { + await frameOnStop(p, 'Meiji Jingu') + await aimAtLeg(p) + }, + }, + { + target: aimedLine, + hover: hoverAimedLine, + act: async p => { + await p.mouse.click(aimedAt.x, aimedAt.y) + await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(1) + await expect(viaHandle(p)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + viaDayId = (await listVias(p))[0]?.day_id ?? 0 + await settle(p) + }, + // Which day the click landed on is the router's answer, not ours. + target: p => { + const day = seededTrip().dayIds.indexOf(viaDayId) + 1 + return dayCard(p, day > 0 ? day : 1).locator('header') + }, + }, + { + target: viaHandle, + act: async p => { + const box = await viaHandle(p).boundingBox() + if (!box) throw new Error('the via handle has no box') + const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + const before = JSON.stringify((await listVias(p))[0] ?? {}) + // A Leaflet drag, not an HTML5 one: the handle listens for the raw + // pointer, so `dragTo` would move nothing. + await p.mouse.move(from.x, from.y) + await p.mouse.down() + await p.mouse.move(from.x + 45, from.y + 40, { steps: 10 }) + await p.mouse.move(from.x + 90, from.y + 80, { steps: 10 }) + await p.mouse.up() + await expect + .poll(async () => JSON.stringify((await listVias(p))[0] ?? {}), { timeout: 60_000 }) + .not.toBe(before) + await settle(p) + }, + }, + { + target: viaHandle, + act: async p => { + const box = await viaHandle(p).boundingBox() + if (!box) throw new Error('the via handle has no box') + await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) + await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(0) + await settle(p) + }, + }, + ], + cleanup: clearVias, + }, + 'roadtrip-alternatives': { + guide: guide('roadtrip-alternatives'), + start: openRoadtrip, + steps: [ + { + target: p => dayCard(p, 2).getByRole('button', { name: /Other ways$/ }).last(), + act: async p => { + await dayCard(p, 2).getByRole('button', { name: /Other ways$/ }).last().click() + await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 }) + await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 }) + await settle(p) + }, + }, + only(altBar), + { + prepare: async p => { + await altBar(p).getByRole('button', { name: /km/ }).last().hover() + await settle(p) + }, + target: p => altBar(p).getByRole('button', { name: /km/ }).last(), + }, + { + target: p => altBar(p).getByRole('button', { name: 'Close' }), + act: async p => { + await altBar(p).getByRole('button', { name: 'Close' }).click() + await expect(p.getByText('Ways to drive this leg')).toHaveCount(0) + await settle(p) + }, + }, + ], + // Nothing is chosen here, but choosing an entry writes a via, so a misclick + // must not follow the run into the next guide. + cleanup: clearVias, + }, + 'roadtrip-limits': { + guide: guide('roadtrip-limits'), + start: openRoadtrip, + steps: [ + { + target: settingsCard, + act: async p => { + await settingsCard(p).click() + await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => modal(p).getByTestId('limit-legMinutes').locator('xpath=ancestor::section[1]'), + act: async p => { + await modal(p).getByTestId('limit-legMinutes').fill('60') + await p.keyboard.press('Tab') + await expect(modal(p).getByTestId('limit-legMinutes')).toHaveValue('60') + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('group', { name: 'What you drive' }), + act: async p => { + await modal(p).getByText('Electric', { exact: true }).click() + await expect(modal(p).getByRole('radio', { name: 'Electric' })).toBeChecked() + await settle(p) + }, + }, + { + target: p => modal(p).getByTestId('limit-range').locator('xpath=ancestor::label[1]'), + act: async p => { + await modal(p).getByRole('button', { name: 'Work it out from the car' }).click() + await expect(modal(p).getByRole('spinbutton', { name: /^Battery, / })).toBeVisible() + await settle(p) + }, + }, + // Not toggled on purpose: each of these switches sends every leg of the + // trip back through the avoidance router, which is minutes of waiting for + // one picture of a row of switches. + only(p => modal(p).getByRole('button', { name: 'Toll roads' }).locator('xpath=ancestor::section[1]')), + { + prepare: async p => { + await closeModal(p) + await settle(p) + }, + target: settingsCard, + act: async p => { + // Both halves: the badge the fixture leaves reads "1 h 30 min", which + // contains "1 h", so the shortened limit only shows in what is NOT there. + await expect(settingsCard(p)).toContainText('1 h', { timeout: 30_000 }) + await expect(settingsCard(p)).not.toContainText('1 h 30 min') + await settle(p) + }, + }, + ], + // The fixture writes the whole preference set, so re-running it is the undo. + cleanup: p => ensureRoadtripFixtures(p.request), + }, + 'roadtrip-day-window': { + guide: guide('roadtrip-day-window'), + start: openRoadtrip, + steps: [ + { + prepare: async p => { + await settingsCard(p).click() + await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible() + await settle(p) + }, + target: p => modal(p).getByRole('group', { name: 'Daily travel times' }), + }, + { + target: p => modal(p).getByRole('textbox', { name: 'Day start time' }), + act: async p => { + await modal(p).getByRole('textbox', { name: 'Day start time' }).fill('09:00') + await expect(modal(p).getByRole('textbox', { name: 'Day start time' })).toHaveValue('09:00') + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('textbox', { name: 'Day end time' }), + act: async p => { + await modal(p).getByRole('textbox', { name: 'Day end time' }).fill('17:30') + await p.keyboard.press('Tab') + await expect(modal(p).getByRole('textbox', { name: 'Day end time' })).toHaveValue('17:30') + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('group', { name: 'End the day' }), + act: async p => { + await modal(p).getByText('At the last place', { exact: true }).click() + await expect(modal(p).getByRole('radio', { name: 'At the last place' })).toBeChecked() + await settle(p) + }, + }, + { + prepare: async p => { + await closeModal(p) + await settle(p) + }, + target: settingsCard, + act: async p => { + await expect(settingsCard(p)).toContainText('09:00', { timeout: 60_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + await clearDayBoundaries(p) + await ensureRoadtripFixtures(p.request) + }, + }, + 'roadtrip-refuel': { + guide: guide('roadtrip-refuel'), + start: async p => { + refuelStop = '' + await openRoadtrip(p) + }, + steps: [ + only(dryBand), + { + target: p => p.getByRole('button', { name: 'Find fuel' }).first(), + act: async p => { + await p.getByRole('button', { name: 'Find fuel' }).first().click() + await expect(p.getByText('Looking along the route…')).toHaveCount(0, { timeout: 180_000 }) + await expect(addFuelStop(p)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + only(p => dryBand(p).getByRole('list').first()), + { + prepare: async p => { + const label = (await addFuelStop(p).getAttribute('aria-label')) ?? '' + refuelStop = label.replace(/^Add /, '').replace(/ as a fuel stop$/, '') + await addFuelStop(p).hover() + await settle(p) + }, + target: addFuelStop, + act: async p => { + await addFuelStop(p).click() + await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 }) + await modal(p).getByRole('button', { name: 'Add', exact: true }).click() + await expect(modal(p)).toHaveCount(0, { timeout: 30_000 }) + await expect + .poll(async () => (await listPlaces(p)).some(x => x.name === refuelStop), { timeout: 30_000 }) + .toBe(true) + await settle(p) + }, + }, + ], + cleanup: p => deleteByName(p, refuelStop), + }, + 'roadtrip-track': { + guide: guide('roadtrip-track'), + start: openRoadtrip, + steps: [ + { + target: p => dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }), + act: async p => { + await dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }).click() + await expect(modal(p).getByRole('heading', { name: 'Day 7 follows a track' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('list').first(), + act: async p => { + await modal(p).getByRole('button', { name: /^Arashiyama loop/ }).click() + await expect(modal(p).getByRole('button', { name: 'Follow this track' })).toBeEnabled() + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('button', { name: 'Follow this track' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Follow this track' }).click() + // One router round trip per refinement round, on a route that is + // being pulled onto a track metre by metre. + await expect(modal(p).getByText(/via points placed|already followed this track/)).toBeVisible({ + timeout: 240_000, + }) + await settle(p) + }, + }, + { + target: dialog, + act: async p => { + await closeModal(p) + await settle(p) + }, + }, + ], + cleanup: async p => { + await closeModal(p) + await clearVias(p) + }, + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureRoadtripFixtures(request) +}) + +test.afterAll(async ({ request }) => { + // Instance-wide, not trip-scoped: left on, every trip's left column grows the + // Days / Road trip switch and every other screen's pictures change. Here + // rather than in the last guide's cleanup, so a failed run puts it back too. + await request.put('/api/admin/addons/roadtrip', { data: { enabled: false } }) +}) + +test('every registered road trip guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripRoadtripContext.guides].sort()) +}) + +test('hero: trip-roadtrip', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripRoadtripContext.id, openRoadtrip) +}) + +for (const id of tripRoadtripContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/e2e/help/trip-shared.ts b/client/e2e/help/trip-shared.ts index 8be1e667c..b95c0628b 100644 --- a/client/e2e/help/trip-shared.ts +++ b/client/e2e/help/trip-shared.ts @@ -47,10 +47,21 @@ export async function openTrip(page: Page, opts: { tab?: string; day?: number | /** Click the day's header: selects it and opens its details panel. */ export async function selectDay(page: Page, n: number): Promise { - await page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }).click() + const header = page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }) + // The header carries buttons of its own: the booked night in the middle, the + // transit, note and fold actions on the right. A click on the centre lands on + // the booked night as soon as a day has one, which selects nothing, so the + // click goes to the day's number at the left edge. + await header.click({ position: { x: 22, y: 20 } }) + // The details panel is the selection: without it the day's route tools, the + // + on a place row and To day are all absent, and a guide fails far from here. + await expect(dayDetails(page)).toBeVisible({ timeout: 15_000 }) await settle(page) } +/** The day details panel: the one fixed, floating card over the map. */ +export const dayDetails = (page: Page) => page.locator('div.fixed.z-50').first() + /** The details panel's close is the one unlabelled X button over the map. */ export async function closeDayDetails(page: Page): Promise { const close = page.locator('button:has(svg.lucide-x)').last() @@ -60,3 +71,22 @@ export async function closeDayDetails(page: Page): Promise { } } +/** + * Open the trip with day `n` selected and its details panel standing. The panel + * IS the selection — closing it deselects the day — and the day's route tools + * and the "+" at the end of a place row render for a selected day only, so a + * guide about either of them starts here rather than at `openTrip`. + */ +export async function openTripOnDay(page: Page, n = 1): Promise { + await openTrip(page, { day: null }) + await selectDay(page, n) +} + +/** + * Dismiss a `ContextMenu`. It closes on a document click and ignores Escape, + * and a real click would land on the map or on the row underneath. + */ +export async function closeMenu(page: Page): Promise { + await page.evaluate(() => document.body.click()) + await settle(page) +} diff --git a/client/e2e/help/trip-transports.guide.ts b/client/e2e/help/trip-transports.guide.ts new file mode 100644 index 000000000..03f99dcfb --- /dev/null +++ b/client/e2e/help/trip-transports.guide.ts @@ -0,0 +1,472 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { seededTrip, ensureTransportFixtures, TRANSIT_JOURNEY } from './fixtures' +import { openTrip, openTripOnDay, selectDay, modal, dialog, portalDialog } from './trip-shared' +import { tripTransportsContext, tripTransportsGuides } from '../../src/help/contexts/tripTransports' +import type { HelpGuide } from '../../src/help/types' + +/** + * Actions for the Transports tab of a trip, keyed by the ids in + * `src/help/contexts/tripTransports.ts`. They run on the seeded "Autumn in + * Japan" with the transports `ensureTransportFixtures` adds up front, so the + * tab has a type chip per group and a planned connection to open; what a guide + * creates it takes away again in `cleanup`, so the next one starts the same. + */ + +const guide = (id: string): HelpGuide => { + const g = tripTransportsGuides.find(x => x.id === id) + if (!g) throw new Error(`no registered guide "${id}"`) + return g +} + +/** The flight the seed creates. Its row on day 1 carries the Departure span label. */ +const FLIGHT = { title: 'LH716', row: /^Departure LH716/ } +/** + * The connection the fixtures store on day 6, so no guide has to search for one + * first. Its card writes the two ends either side of an arrow icon, with no + * text between them, so `hasText` gets the first end rather than the title. + */ +const JOURNEY = { title: TRANSIT_JOURNEY, onCard: 'Arashiyama Bamboo Grove' } +/** What plan-transit plans on day 1; the title the panel builds from the two ends. */ +const PLANNED = 'Senso-ji Temple → teamLab Planets' +/** What add-transport creates. Both ends are searched, so the map has something to draw. */ +const TAXI = { + title: 'Taxi to teamLab Planets', + from: { query: 'Senso-ji Temple Tokyo', match: /^Sens/ }, + to: { query: 'teamLab Planets', match: /^teamLab/ }, +} + +/** A day card's header: number, weather, "Day n", date, and the four action buttons. */ +const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }) +/** A booking card in the tab, told apart by what is written on it. */ +const card = (page: Page, text: string) => page.locator('.bg-surface-card').filter({ hasText: text }).first() +/** A transport's row in the day plan; the whole row is the button that opens it. */ +const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first() +/** A toolbar type chip or a section heading — both are a label followed by their count. */ +const counted = (page: Page, label: string) => page.getByRole('button', { name: new RegExp(`^${label} \\d+$`) }) +/** A labelled field block inside the open dialog. */ +const block = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).locator('xpath=..') +/** The leg-mode popover, told apart from the connector's tooltip by what stands in it. */ +const legMenu = (page: Page) => + page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Use day default' }) }).last() +/** The travel-time connector between two stops of the open day. */ +const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first() +/** The transit search's stop boxes, and the transport form's location boxes. */ +const stopBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search stop or station…').nth(n) +const locationBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search station, port, address…').nth(n) +/** + * Both pickers are an input in its own relative wrapper with the result list as + * the wrapper's second child, so one expression finds either one's suggestions. + */ +const suggestions = (box: Locator) => box.locator('xpath=../..').locator('> div').nth(1) +/** A result of the transit search: the card's own toggle carries the times as its name. */ +const itinerary = (page: Page, n: number) => + modal(page).getByRole('button', { name: /^\d{1,2}:\d{2} – \d{1,2}:\d{2} / }).nth(n) +/** The read-only sheet an endpoint marker on the map opens; its own portal, no shared class. */ +const transportSheet = (page: Page) => portalDialog(page, page.getByRole('button', { name: 'Close' })) + +async function searchLocation(page: Page, box: Locator, query: string, match: RegExp): Promise { + await box.click() + await typeInto(page, box, query) + const first = suggestions(box).locator('button').first() + await expect(first).toBeVisible({ timeout: 25_000 }) + await first.click() + await expect(box).toHaveValue(match, { timeout: 15_000 }) +} + +/** The transit search offers the day's own stops as soon as the box has focus. */ +async function pickStop(page: Page, box: Locator, name: string): Promise { + await box.click() + const option = suggestions(box).getByRole('button', { name }) + await expect(option).toBeVisible({ timeout: 20_000 }) + await option.click() + await expect(box).toHaveValue(name, { timeout: 10_000 }) +} + +async function deleteByTitle(page: Page, ...titles: string[]): Promise { + const { tripId } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/reservations`) + const body = (await res.json()) as { reservations?: { id: number; title: string }[] } | { id: number; title: string }[] + const list = Array.isArray(body) ? body : (body.reservations ?? []) + for (const r of list.filter(x => titles.includes(x.title))) { + await page.request.delete(`/api/trips/${tripId}/reservations/${r.id}`) + } +} + +/** Give a day's legs back to the day's own mode, whichever stop carries the one that was set. */ +async function resetLegModes(page: Page, dayIndex: number): Promise { + const { tripId, dayIds } = seededTrip() + const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`) + const body = (await res.json()) as { assignments?: { id: number }[] } + for (const a of body.assignments ?? []) { + await page.request.put(`/api/trips/${tripId}/assignments/${a.id}/transport`, { data: { transport_mode: null } }) + } +} + +/** The day route toggle stays on until it is clicked again; it is only there while a day is open. */ +async function switchRouteOff(page: Page): Promise { + const route = page.getByRole('button', { name: 'Route', exact: true }).first() + if (await route.isVisible().catch(() => false)) { + await route.click() + await settle(page) + } +} + +const closeModal = async (page: Page): Promise => { + if (await modal(page).isVisible().catch(() => false)) { + await page.keyboard.press('Escape') + await expect(modal(page)).toHaveCount(0) + } +} + +const only = (target: (p: Page) => Locator) => ({ target }) + +const SCRIPTS: Record = { + 'transports-list': { + guide: guide('transports-list'), + start: p => openTrip(p, { tab: 'transports' }), + steps: [ + only(p => p.getByRole('button', { name: 'Transports', exact: true })), + { + target: p => counted(p, 'All').locator('xpath=..'), + act: async p => { + await counted(p, 'Train').click() + await expect(card(p, FLIGHT.title)).toHaveCount(0) + await settle(p) + }, + }, + { + prepare: async p => { + await counted(p, 'All').click() + await expect(card(p, FLIGHT.title)).toBeVisible() + await settle(p) + }, + target: p => counted(p, 'Automated public transit'), + }, + only(p => card(p, FLIGHT.title)), + { + prepare: async p => { + await card(p, FLIGHT.title).getByRole('button', { name: 'Delete' }).click() + await expect(p.getByText('Delete booking?')).toBeVisible() + await beat(p, 300) + }, + target: p => portalDialog(p, p.getByText('Delete booking?')), + // Cancel, not Confirm: the flight has to survive for the next guide. + act: async p => { + await portalDialog(p, p.getByText('Delete booking?')).getByRole('button', { name: 'Cancel' }).click() + await expect(p.getByText('Delete booking?')).toHaveCount(0) + await expect(card(p, FLIGHT.title)).toBeVisible() + }, + }, + ], + cleanup: async p => { + await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } }) + }, + }, + 'add-transport': { + guide: guide('add-transport'), + start: p => openTrip(p), + steps: [ + { + // captureGuide hovers the target before the shot, so the tooltip is in the picture. + target: p => dayHeader(p, 1).getByRole('button', { name: 'Add transport' }), + act: async p => { + await dayHeader(p, 1).getByRole('button', { name: 'Add transport' }).click() + await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible() + await settle(p) + }, + }, + { + target: p => block(p, 'Booking Type'), + act: async p => { + await modal(p).getByRole('button', { name: 'Taxi', exact: true }).click() + await expect(modal(p).getByText('Start time', { exact: true })).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), TAXI.title) + await settle(p) + }, + target: p => block(p, 'Title *'), + }, + { + prepare: async p => { + await searchLocation(p, locationBox(p, 0), TAXI.from.query, TAXI.from.match) + await searchLocation(p, locationBox(p, 1), TAXI.to.query, TAXI.to.match) + await settle(p) + }, + target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'), + }, + { + prepare: async p => { + // Between the two stops of day 1, which the fixtures give 09:30 and 13:00. + await modal(p).getByPlaceholder('00:00').nth(0).fill('12:00') + await modal(p).getByPlaceholder('00:00').nth(1).fill('12:30') + await settle(p) + }, + target: p => modal(p).getByText('Date', { exact: true }).locator('xpath=../..'), + }, + { + target: p => modal(p).getByRole('button', { name: 'Add', exact: true }), + act: async p => { + await modal(p).getByRole('button', { name: 'Add', exact: true }).click() + await expect(modal(p)).toHaveCount(0) + await expect(transportRow(p, new RegExp(TAXI.title))).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteByTitle(p, TAXI.title), + }, + 'plan-transit': { + guide: guide('plan-transit'), + start: p => openTrip(p), + steps: [ + { + target: p => dayHeader(p, 1).getByRole('button', { name: 'Public transit' }), + act: async p => { + await dayHeader(p, 1).getByRole('button', { name: 'Public transit' }).click() + await expect(modal(p).getByRole('heading', { name: 'Public transit' })).toBeVisible() + await settle(p) + }, + }, + { + // The quick picks open on focus, so the day's own stops are in the picture. + prepare: async p => { + await stopBox(p, 0).click() + await expect(suggestions(stopBox(p, 0)).getByRole('button', { name: 'Senso-ji Temple' })).toBeVisible() + await beat(p, 300) + }, + target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'), + act: async p => { + await pickStop(p, stopBox(p, 0), 'Senso-ji Temple') + await pickStop(p, stopBox(p, 1), 'teamLab Planets') + await expect(modal(p).getByRole('button', { name: 'Search' })).toBeEnabled() + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('button', { name: 'Depart' }).locator('xpath=ancestor::div[3]'), + act: async p => { + await modal(p).getByRole('button', { name: 'Fewer transfers' }).click() + await settle(p) + }, + }, + { + target: p => modal(p).getByRole('button', { name: 'Tram' }).locator('xpath=ancestor::div[2]'), + act: async p => { + await modal(p).getByRole('button', { name: 'Search' }).click() + await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 }) + await settle(p) + }, + }, + { + prepare: async p => { + await itinerary(p, 0).click() + await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible() + await beat(p, 300) + }, + target: p => itinerary(p, 0).locator('xpath=..'), + }, + { + target: p => modal(p).getByRole('button', { name: 'Add to day' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Add to day' }).click() + await expect(modal(p)).toHaveCount(0, { timeout: 30_000 }) + await expect(transportRow(p, /Senso-ji Temple.*teamLab Planets/)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + }, + ], + cleanup: p => deleteByTitle(p, PLANNED), + }, + 'change-transit-route': { + guide: guide('change-transit-route'), + start: p => openTrip(p, { tab: 'transports' }), + steps: [ + { + target: p => card(p, JOURNEY.onCard), + act: async p => { + await card(p, JOURNEY.onCard).click() + await expect(modal(p).getByRole('heading', { name: 'Public transit journey' })).toBeVisible() + await settle(p) + }, + }, + only(p => modal(p).getByText('Itinerary', { exact: true }).locator('xpath=..')), + { + target: p => modal(p).getByRole('button', { name: 'Change route' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Change route' }).click() + await expect(modal(p).getByRole('button', { name: 'Search' })).toBeVisible() + await settle(p) + }, + }, + { + prepare: async p => { + await modal(p).getByRole('button', { name: 'Search' }).click() + await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 }) + await itinerary(p, 1).click() + await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible() + await beat(p, 300) + }, + target: p => modal(p).getByRole('button', { name: 'Add to day' }), + act: async p => { + await modal(p).getByRole('button', { name: 'Add to day' }).click() + await expect(modal(p)).toHaveCount(0, { timeout: 30_000 }) + await settle(p) + }, + }, + ], + // The connection now carries another itinerary: put the canned one back. + cleanup: async p => { + await deleteByTitle(p, JOURNEY.title) + await ensureTransportFixtures(p.request) + }, + }, + 'leg-travel-mode': { + guide: guide('leg-travel-mode'), + // The route tools only render for the day that is open, and closing its + // details panel closes the day again, so day 1 stays open here. + start: p => openTripOnDay(p, 1), + steps: [ + { + target: p => p.getByRole('button', { name: 'Route', exact: true }).first(), + act: async p => { + await p.getByRole('button', { name: 'Route', exact: true }).first().click() + await expect(connector(p)).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + only(connector), + { + prepare: async p => { + await connector(p).click() + await expect(legMenu(p)).toBeVisible() + await beat(p, 300) + }, + target: legMenu, + }, + { + target: p => legMenu(p).getByRole('button', { name: 'Walking' }), + act: async p => { + await legMenu(p).getByRole('button', { name: 'Walking' }).click() + await expect(connector(p).locator('svg.lucide-footprints')).toBeVisible({ timeout: 30_000 }) + await settle(p) + }, + }, + ], + cleanup: async p => { + await switchRouteOff(p) + await resetLegModes(p, 0) + }, + }, + 'edit-transport': { + guide: guide('edit-transport'), + start: p => openTrip(p), + steps: [ + { + target: p => transportRow(p, FLIGHT.row), + act: async p => { + await transportRow(p, FLIGHT.row).click() + await expect(modal(p).getByRole('heading', { name: 'Edit transport' })).toBeVisible() + await settle(p) + }, + }, + only(dialog), + only(p => block(p, 'Route')), + { + // Escape rather than Update: nothing about the seeded flight may change. + target: p => modal(p).getByRole('button', { name: 'Update', exact: true }), + act: closeModal, + }, + ], + }, + 'transport-on-map': { + guide: guide('transport-on-map'), + start: p => openTrip(p), + steps: [ + { + target: p => transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }), + act: async p => { + await transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }).click() + await expect(transportRow(p, FLIGHT.row).getByRole('button', { name: 'Hide booking routes' })).toBeVisible() + await settle(p) + await p.waitForTimeout(1500) + }, + }, + // The map is on Tokyo, so the Frankfurt end is off screen and the Haneda + // one, drawn second, is the marker the reader can actually see. + only(p => p.locator('.trek-endpoint-marker').last()), + { + prepare: async p => { + await p.locator('.trek-endpoint-marker').last().click() + await expect(transportSheet(p)).toBeVisible() + await settle(p) + }, + target: transportSheet, + act: async p => { + await transportSheet(p).getByRole('button', { name: 'Close' }).click() + await expect(transportSheet(p)).toHaveCount(0) + await settle(p) + }, + }, + { + target: p => p.getByRole('button', { name: 'Show all booking routes' }), + act: async p => { + await p.getByRole('button', { name: 'Show all booking routes' }).click() + await expect(p.getByRole('button', { name: 'Hide all booking routes' })).toBeVisible() + await settle(p) + await p.waitForTimeout(1500) + }, + }, + { + // Day 6 carries the stored connection, and it has the two located stops + // the route tools need, so its Route toggle is there to switch on. + prepare: async p => { + await selectDay(p, 6) + await expect(p.getByRole('button', { name: 'Route', exact: true }).first()).toBeVisible() + await beat(p, 300) + }, + target: p => p.getByRole('button', { name: 'Route', exact: true }).first(), + act: async p => { + await p.getByRole('button', { name: 'Route', exact: true }).first().click() + await settle(p) + await p.waitForTimeout(1500) + }, + }, + ], + cleanup: async p => { + await switchRouteOff(p) + const { tripId } = seededTrip() + await p.evaluate(id => { + try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ } + }, tripId) + }, + }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request }) => { + await ensureTransportFixtures(request) +}) + +test('every registered transports guide has a script, and only those', async () => { + expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTransportsContext.guides].sort()) +}) + +test('hero: trip-transports', async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureHero(page, tripTransportsContext.id, p => openTrip(p, { tab: 'transports' })) +}) + +for (const id of tripTransportsContext.guides) { + test(`pictures: ${id}`, async ({ page }) => { + await page.setViewportSize(VIEWPORT) + await captureGuide(page, SCRIPTS[id]) + }) +} diff --git a/client/src/help/contexts/tripBookings.ts b/client/src/help/contexts/tripBookings.ts new file mode 100644 index 000000000..b742d1671 --- /dev/null +++ b/client/src/help/contexts/tripBookings.ts @@ -0,0 +1,37 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the Bookings tab of a trip: the places to stay, the tables, the + * tickets and everything else that was booked, what a card says about one, how + * one is made by hand or read out of the confirmation that arrived, and how it + * is tied to the plan, to a document and to a cost. Flights, trains and the + * other ways of getting about belong to the Transports tab and never show here. + * A screen under `trip`; the step actions live in `e2e/help/trip-bookings.guide.ts`. + */ + +const BOOKINGS = 'Reservations-and-Bookings' +const CREATE = { slug: BOOKINGS, anchor: 'creating-a-reservation' } +const SECTIONS = { slug: BOOKINGS, anchor: 'pending-and-confirmed' } +const CHANGE = { slug: BOOKINGS, anchor: 'editing-and-deleting' } +const IMPORT = { slug: BOOKINGS, anchor: 'import-from-booking-confirmation' } + +export const { context: tripBookingsContext, guides: tripBookingsGuides } = defineScreen({ + id: 'trip-bookings', + parent: 'trip', + route: '/trips/:id?tab=buchungen', + icon: 'ticket', + bullets: 6, + docs: [{ slug: BOOKINGS }, IMPORT, { slug: 'Accommodations' }], + guides: [ + ['create-booking', 'plus', 'guide', 6, 3, CREATE, ['booking-hotel', 'link-booking', 'edit-booking', 'add-transport'], true], + ['booking-hotel', 'building', 'guide', 5, 3, { slug: 'Accommodations', anchor: 'creating-an-accommodation' }, ['create-booking', 'delete-booking', 'add-accommodation'], true], + ['link-booking', 'link', 'quick', 4, 2, CREATE, ['create-booking', 'bookings-in-plan', 'day-bookings', 'place-booking']], + ['booking-travelers', 'users', 'quick', 4, 2, SECTIONS, ['filter-bookings', 'add-member'], true], + ['booking-files', 'folder', 'guide', 4, 3, { slug: 'Documents-and-Files', anchor: 'linking-files-to-places-reservations-or-assignments' }, ['create-booking', 'import-booking-file', 'files-link']], + ['booking-cost', 'coins', 'guide', 5, 3, { slug: 'Budget-Tracking', anchor: 'expenses-linked-to-a-booking-or-a-place' }, ['create-booking', 'edit-booking', 'expense-from-booking'], true], + ['filter-bookings', 'filter', 'quick', 5, 2, SECTIONS, ['booking-travelers', 'edit-booking']], + ['import-booking-file', 'fileInput', 'guide', 4, 3, IMPORT, ['booking-files', 'create-booking']], + ['edit-booking', 'pencil', 'quick', 4, 3, CHANGE, ['create-booking', 'delete-booking']], + ['delete-booking', 'trash', 'quick', 3, 2, CHANGE, ['edit-booking', 'booking-hotel']], + ], +}) diff --git a/client/src/help/contexts/tripCollab.ts b/client/src/help/contexts/tripCollab.ts new file mode 100644 index 000000000..82b667c10 --- /dev/null +++ b/client/src/help/contexts/tripCollab.ts @@ -0,0 +1,35 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the Collab tab of a trip: the group's chat, the shared notes and + * links, the polls it decides with, and the list of what is coming up next. + * A screen under `trip`; the step actions live in `e2e/help/trip-collab.guide.ts`. + * + * The order of the guides is the order the media run walks them in, and the + * chat's delete step leaves a tombstone that cannot be taken back, so + * `trip-chat` is last: no earlier picture can catch it. + */ + +const RTC = 'Real-Time-Collaboration' +const CHAT = 'Collab-Chat' +const NOTES = 'Collab-Notes' +const POLLS = 'Collab-Polls' +const NEXT = 'Whats-Next-Widget' + +export const { context: tripCollabContext, guides: tripCollabGuides } = defineScreen({ + id: 'trip-collab', + parent: 'trip', + route: '/trips/:id?tab=collab', + icon: 'users', + bullets: 6, + docs: [{ slug: RTC }, { slug: CHAT }, { slug: NOTES }, { slug: POLLS }, { slug: NEXT }], + guides: [ + ['write-note', 'pencil', 'guide', 6, 4, { slug: NOTES, anchor: 'creating-a-note' }, ['shared-links', 'trip-chat'], true], + ['shared-links', 'link', 'guide', 4, 3, { slug: RTC, anchor: 'links' }, ['write-note'], true], + ['create-poll', 'chart', 'guide', 6, 4, { slug: POLLS, anchor: 'creating-a-poll' }, ['vote-poll', 'close-poll'], true], + ['vote-poll', 'thumbsUp', 'quick', 4, 2, { slug: POLLS, anchor: 'voting' }, ['create-poll', 'close-poll'], true], + ['close-poll', 'lock', 'quick', 3, 2, { slug: POLLS, anchor: 'closing-a-poll-manually' }, ['create-poll', 'vote-poll']], + ['whats-next', 'sparkles', 'quick', 3, 3, { slug: NEXT, anchor: 'what-it-shows' }, ['set-stop-times', 'read-day-plan']], + ['trip-chat', 'messages', 'guide', 6, 4, { slug: CHAT, anchor: 'sending-messages' }, ['write-note', 'add-member'], true], + ], +}) diff --git a/client/src/help/contexts/tripCosts.ts b/client/src/help/contexts/tripCosts.ts new file mode 100644 index 000000000..9f27afb04 --- /dev/null +++ b/client/src/help/contexts/tripCosts.ts @@ -0,0 +1,32 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the Costs tab of a trip: recording what something cost, who put the + * money down and who it is shared with, in whatever currency the receipt was + * in, and reading back who owes whom and what the trip costs each traveler. + * A screen under `trip`; the step actions live in `e2e/help/trip-costs.guide.ts`. + */ + +const COSTS = 'Budget-Tracking' +const CURRENCIES = 'Currencies' +const SETTLEMENT = { slug: COSTS, anchor: 'settlement-calculator' } +const ITEMS = { slug: COSTS, anchor: 'expense-items' } + +export const { context: tripCostsContext, guides: tripCostsGuides } = defineScreen({ + id: 'trip-costs', + parent: 'trip', + route: '/trips/:id?tab=finanzplan', + icon: 'wallet', + bullets: 6, + docs: [{ slug: COSTS }, { slug: CURRENCIES }], + guides: [ + ['add-expense', 'plus', 'guide', 6, 3, ITEMS, ['split-expense', 'expense-payers', 'expense-currency'], true], + ['expense-payers', 'userRound', 'guide', 4, 3, { slug: COSTS, anchor: 'who-paid' }, ['add-expense', 'settle-up']], + ['split-expense', 'users', 'guide', 5, 3, { slug: COSTS, anchor: 'splitting-costs' }, ['add-expense', 'expense-payers'], true], + ['expense-currency', 'coins', 'guide', 5, 3, { slug: CURRENCIES, anchor: 'expense-currency' }, ['add-expense', 'edit-trip', 'language-region'], true], + ['filter-costs', 'filter', 'quick', 5, 2, ITEMS, ['add-expense', 'final-budget'], true], + ['settle-up', 'checkCircle', 'tour', 6, 3, SETTLEMENT, ['final-budget', 'expense-payers'], true], + ['final-budget', 'chart', 'guide', 4, 2, SETTLEMENT, ['settle-up', 'add-expense']], + ['expense-from-booking', 'link', 'quick', 4, 2, { slug: COSTS, anchor: 'expenses-linked-to-a-booking-or-a-place' }, ['add-expense', 'bookings-in-plan', 'create-booking'], true], + ], +}) diff --git a/client/src/help/contexts/tripDayDetail.ts b/client/src/help/contexts/tripDayDetail.ts new file mode 100644 index 000000000..f4a2995a8 --- /dev/null +++ b/client/src/help/contexts/tripDayDetail.ts @@ -0,0 +1,31 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the day details panel of a trip's plan: the panel a day header + * opens over the map, with the day's name and date, the weather where you + * will be, the bookings that fall on the day and the nights booked for it. + * A screen under `trip`; the step actions live in `e2e/help/trip-day-detail.guide.ts`. + */ + +const DAYS = 'Day-Plans-and-Notes' +const STAY = 'Accommodations' +const WEATHER = 'Weather-Forecasts' +const PANEL = { slug: DAYS, anchor: 'day-detail-panel' } +const IN_PANEL = { slug: STAY, anchor: 'in-the-day-detail-panel' } + +export const { context: tripDayDetailContext, guides: tripDayDetailGuides } = defineScreen({ + id: 'trip-day-detail', + parent: 'trip', + route: '/trips/:id?tab=plan', + icon: 'panelRight', + bullets: 6, + docs: [PANEL, IN_PANEL, { slug: WEATHER }], + guides: [ + ['day-panel', 'panelRight', 'guide', 6, 2, PANEL, ['day-weather', 'day-bookings', 'read-day-plan'], true], + ['day-weather', 'eye', 'quick', 4, 3, { slug: WEATHER, anchor: 'what-is-shown' }, ['day-panel', 'read-day-plan']], + ['rename-day', 'tags', 'quick', 4, 2, PANEL, ['manage-days', 'day-note'], true], + ['add-accommodation', 'building', 'guide', 6, 3, { slug: STAY, anchor: 'creating-an-accommodation' }, ['create-place', 'edit-accommodation', 'booking-hotel'], true], + ['edit-accommodation', 'pencil', 'guide', 5, 2, IN_PANEL, ['add-accommodation', 'day-bookings']], + ['day-bookings', 'ticket', 'quick', 4, 2, { slug: DAYS, anchor: 'multi-day-reservations' }, ['bookings-in-plan', 'create-booking', 'add-accommodation']], + ], +}) diff --git a/client/src/help/contexts/tripFiles.ts b/client/src/help/contexts/tripFiles.ts new file mode 100644 index 000000000..f8fe17d33 --- /dev/null +++ b/client/src/help/contexts/tripFiles.ts @@ -0,0 +1,28 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the Files tab of a trip: getting documents in, giving them a note + * and links into the plan, finding them again, reading them on the spot and + * getting them back out of the trash. + * A screen under `trip`; the step actions live in `e2e/help/trip-files.guide.ts`. + */ + +const FILES = 'Documents-and-Files' +const LINKING = { slug: FILES, anchor: 'linking-files-to-places-reservations-or-assignments' } + +export const { context: tripFilesContext, guides: tripFilesGuides } = defineScreen({ + id: 'trip-files', + parent: 'trip', + route: '/trips/:id?tab=dateien', + icon: 'folder', + bullets: 6, + docs: [{ slug: FILES }, { slug: 'Document-Sync' }], + guides: [ + ['files-upload', 'upload', 'guide', 4, 3, { slug: FILES, anchor: 'uploading' }, ['files-link', 'files-trash'], true], + ['files-link', 'link', 'guide', 5, 3, LINKING, ['files-upload', 'place-files', 'link-booking'], true], + ['files-star', 'star', 'quick', 3, 2, { slug: FILES, anchor: 'starring' }, ['files-filter', 'files-preview'], true], + ['files-filter', 'filter', 'quick', 4, 2, { slug: FILES, anchor: 'browsing-and-filtering' }, ['files-star', 'files-preview']], + ['files-preview', 'eye', 'guide', 5, 3, { slug: FILES, anchor: 'previewing-files' }, ['files-filter', 'files-upload']], + ['files-trash', 'trash', 'guide', 5, 3, { slug: FILES, anchor: 'trash' }, ['files-upload', 'files-star'], true], + ], +}) diff --git a/client/src/help/contexts/tripLists.ts b/client/src/help/contexts/tripLists.ts new file mode 100644 index 000000000..ed0aa5084 --- /dev/null +++ b/client/src/help/contexts/tripLists.ts @@ -0,0 +1,31 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the Lists tab of a trip: the packing list (lists of items, who + * brings what, what it weighs and which bag it is in) and the to-do list + * (tasks with a list, a priority, a due date and someone's name on them). + * A screen under `trip`; the step actions live in `e2e/help/trip-lists.guide.ts`. + */ + +const PACKING = 'Packing-Lists' +const TODOS = 'Todos-and-Tasks' +const TEMPLATES = 'Packing-Templates' + +export const { context: tripListsContext, guides: tripListsGuides } = defineScreen({ + id: 'trip-lists', + parent: 'trip', + route: '/trips/:id?tab=listen', + icon: 'listChecks', + bullets: 6, + docs: [{ slug: PACKING }, { slug: TODOS }, { slug: TEMPLATES }], + guides: [ + ['packing-categories', 'tags', 'guide', 5, 3, { slug: PACKING, anchor: 'categories' }, ['check-off-packing', 'apply-packing-template'], true], + ['check-off-packing', 'checkCircle', 'guide', 5, 3, { slug: PACKING, anchor: 'progress-bar' }, ['packing-categories', 'share-packing-item'], true], + ['apply-packing-template', 'library', 'guide', 4, 3, { slug: TEMPLATES, anchor: 'applying-a-template' }, ['packing-templates', 'import-packing-list'], true], + ['import-packing-list', 'fileInput', 'quick', 4, 2, { slug: PACKING, anchor: 'importing-a-list' }, ['apply-packing-template', 'packing-bags'], true], + ['share-packing-item', 'users', 'guide', 5, 3, { slug: PACKING, anchor: 'the-three-tiers' }, ['packing-categories', 'add-member']], + ['packing-bags', 'archive', 'tour', 6, 3, { slug: PACKING, anchor: 'bag-tracking' }, ['toggle-addon', 'import-packing-list'], true], + ['create-todo', 'plus', 'guide', 6, 3, { slug: TODOS, anchor: 'adding-tasks' }, ['todo-filters'], true], + ['todo-filters', 'filter', 'guide', 5, 2, { slug: TODOS, anchor: 'sidebar-filters' }, ['create-todo']], + ], +}) diff --git a/client/src/help/contexts/tripMap.ts b/client/src/help/contexts/tripMap.ts new file mode 100644 index 000000000..0df63bb54 --- /dev/null +++ b/client/src/help/contexts/tripMap.ts @@ -0,0 +1,29 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the map in the middle of a trip's plan: what the pins, badges and + * bubbles mean, the places the map itself can find, and the switches along its + * edges, satellite, the whole trip with its distances and the booking routes. + * A screen under `trip`; the step actions live in `e2e/help/trip-map.guide.ts`. + */ + +const MAP = 'Map-Features' +const SETTINGS = 'Map-Settings' +const OVERLAY = { slug: MAP, anchor: 'reservation-and-transport-overlay' } + +export const { context: tripMapContext, guides: tripMapGuides } = defineScreen({ + id: 'trip-map', + parent: 'trip', + route: '/trips/:id?tab=plan', + icon: 'map', + bullets: 7, + docs: [{ slug: MAP }, { slug: SETTINGS }], + guides: [ + ['map-markers', 'mapPin', 'guide', 5, 3, { slug: MAP, anchor: 'place-markers' }, ['read-day-plan', 'place-detail', 'day-route']], + ['map-nearby-places', 'search', 'guide', 6, 3, { slug: 'Places-and-Search', anchor: 'exploring-the-map-by-category' }, ['map-add-place', 'create-place'], true], + ['map-add-place', 'plus', 'guide', 4, 2, { slug: MAP, anchor: 'right-click-middle-click-to-create-a-place' }, ['create-place', 'map-nearby-places'], true], + ['map-satellite', 'globe', 'quick', 3, 2, { slug: MAP, anchor: 'satellite-view' }, ['map-provider', 'default-map']], + ['map-whole-trip', 'route', 'guide', 4, 3, { slug: MAP, anchor: 'the-whole-trip-at-once' }, ['day-route', 'export-plan'], true], + ['map-booking-routes', 'ticket', 'guide', 5, 3, OVERLAY, ['bookings-in-plan', 'travel-map-prefs', 'add-transport', 'create-booking']], + ], +}) diff --git a/client/src/help/contexts/tripPlace.ts b/client/src/help/contexts/tripPlace.ts new file mode 100644 index 000000000..5c0548b9c --- /dev/null +++ b/client/src/help/contexts/tripPlace.ts @@ -0,0 +1,33 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the card that floats over the map once a place is picked: reading + * what the trip knows about a place, rating it, giving it a picture and files, + * putting it on the open day and off it again, who is coming, the booking + * pinned to the stop, the map apps, and a track’s colour and length. + * A screen under `trip`; the step actions live in `e2e/help/trip-place.guide.ts`. + */ + +const PLACES = 'Places-and-Search' +const FIELDS = { slug: PLACES, anchor: 'place-fields' } + +export const { context: tripPlaceContext, guides: tripPlaceGuides } = defineScreen({ + id: 'trip-place', + parent: 'trip', + route: '/trips/:id?tab=plan', + icon: 'landmark', + bullets: 6, + docs: [{ slug: PLACES }, { slug: 'Trip-Planner-Overview' }], + guides: [ + ['read-place', 'eye', 'guide', 6, 3, FIELDS, ['rate-place', 'place-day-assign', 'edit-place'], true], + ['rate-place', 'star', 'quick', 4, 3, { slug: PLACES, anchor: 'rating-a-place' }, ['read-place', 'filter-places'], true], + ['place-image', 'image', 'guide', 4, 3, { slug: PLACES, anchor: 'custom-place-image' }, ['read-place', 'edit-place'], true], + ['place-day-assign', 'calendarCheck', 'guide', 5, 3, { slug: 'Day-Plans-and-Notes', anchor: 'assigning-places-to-a-day' }, ['place-onto-day', 'remove-from-day', 'undo-change']], + ['place-participants', 'users', 'quick', 4, 3, { slug: 'Trip-Members-and-Sharing', anchor: 'what-a-guest-can-be-assigned-to' }, ['add-member', 'add-guest'], true], + ['place-booking', 'ticket', 'guide', 5, 3, { slug: 'Reservations-and-Bookings', anchor: 'reservation-card-contents' }, ['bookings-in-plan', 'link-booking'], true], + ['place-files', 'folder', 'quick', 4, 3, { slug: 'Documents-and-Files', anchor: 'linking-files-to-places-reservations-or-assignments' }, ['read-place'], true], + ['place-navigation', 'compass', 'quick', 4, 3, { slug: PLACES, anchor: 'opening-a-place-in-a-map-app' }, ['read-place']], + ['place-to-collection', 'bookmark', 'quick', 4, 3, { slug: 'Collections', anchor: 'setting-it-from-a-trip' }, ['select-places', 'place-detail'], true], + ['place-track', 'route', 'guide', 5, 3, { slug: 'Map-Features', anchor: 'gpx-tracks' }, ['import-places-file', 'day-route'], true], + ], +}) diff --git a/client/src/help/contexts/tripRoadtrip.ts b/client/src/help/contexts/tripRoadtrip.ts new file mode 100644 index 000000000..59c21a265 --- /dev/null +++ b/client/src/help/contexts/tripRoadtrip.ts @@ -0,0 +1,33 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for road trip mode: the plan read as one drive. Turning the mode on, + * the stops on the way and how long you stay at them, what is along the route, + * bending a leg with a via point or another way round, the driving settings + * and the travel day, refuelling before the tank runs out, and putting a day + * onto an imported track. + * A screen under `trip`; the step actions live in `e2e/help/trip-roadtrip.guide.ts`. + */ + +const RT = 'Road-Trip' +const LIMITS = { slug: RT, anchor: 'driving-limits-and-range' } + +export const { context: tripRoadtripContext, guides: tripRoadtripGuides } = defineScreen({ + id: 'trip-roadtrip', + parent: 'trip', + route: '/trips/:id?tab=plan', + icon: 'compass', + bullets: 6, + docs: [{ slug: RT }, { slug: RT, anchor: 'what-needs-a-connection' }], + guides: [ + ['roadtrip-mode', 'route', 'guide', 5, 3, { slug: RT, anchor: 'switching-to-road-trip' }, ['read-day-plan', 'roadtrip-limits'], true], + ['roadtrip-stops', 'mapPin', 'guide', 6, 3, { slug: RT, anchor: 'stops-on-the-way' }, ['roadtrip-corridor', 'set-stop-times'], true], + ['roadtrip-corridor', 'search', 'guide', 5, 3, { slug: RT, anchor: 'search-along-the-route' }, ['roadtrip-stops', 'roadtrip-refuel'], true], + ['roadtrip-via', 'map', 'guide', 5, 3, { slug: RT, anchor: 'via-points' }, ['roadtrip-alternatives', 'roadtrip-track']], + ['roadtrip-alternatives', 'repeat', 'quick', 4, 2, { slug: RT, anchor: 'other-ways-and-avoidance' }, ['roadtrip-via', 'roadtrip-limits']], + ['roadtrip-limits', 'sliders', 'guide', 6, 3, LIMITS, ['roadtrip-refuel', 'roadtrip-day-window'], true], + ['roadtrip-day-window', 'calendarRange', 'guide', 5, 3, { slug: RT, anchor: 'daily-travel-times-and-day-endings' }, ['roadtrip-limits', 'roadtrip-mode']], + ['roadtrip-refuel', 'minusCircle', 'guide', 4, 3, LIMITS, ['roadtrip-limits', 'roadtrip-corridor'], true], + ['roadtrip-track', 'flag', 'guide', 4, 2, { slug: RT, anchor: 'following-a-gpx-or-kml-track' }, ['import-places-file', 'roadtrip-via'], true], + ], +}) diff --git a/client/src/help/contexts/tripTransports.ts b/client/src/help/contexts/tripTransports.ts new file mode 100644 index 000000000..6c9986001 --- /dev/null +++ b/client/src/help/contexts/tripTransports.ts @@ -0,0 +1,31 @@ +import { defineScreen } from '../defineScreen' + +/** + * Help for the Transports tab of a trip and for the transports themselves: the + * rides that carry the traveller between the stops, flights, trains, buses, + * cars, taxis and ferries, plus the automated public-transit connections the + * Transitous search plans and the lines all of them draw on the map. A screen + * under `trip`; the step actions live in `e2e/help/trip-transports.guide.ts`. + */ + +const TRANSPORT = 'Transport-Flights-Trains-Cars' +const MAP = 'Map-Features' +const OVERLAY = { slug: MAP, anchor: 'reservation-and-transport-overlay' } + +export const { context: tripTransportsContext, guides: tripTransportsGuides } = defineScreen({ + id: 'trip-transports', + parent: 'trip', + route: '/trips/:id?tab=transports', + icon: 'train', + bullets: 6, + docs: [{ slug: TRANSPORT }, OVERLAY], + guides: [ + ['transports-list', 'eye', 'guide', 5, 2, { slug: TRANSPORT, anchor: 'where-to-create' }, ['edit-transport', 'bookings-in-plan', 'filter-bookings']], + ['add-transport', 'plus', 'guide', 6, 3, { slug: TRANSPORT, anchor: 'common-fields' }, ['plan-transit', 'edit-transport', 'transports-list'], true], + ['plan-transit', 'train', 'guide', 6, 3, { slug: TRANSPORT, anchor: 'public-transit-search' }, ['change-transit-route', 'leg-travel-mode', 'add-transport'], true], + ['change-transit-route', 'repeat', 'quick', 4, 2, { slug: TRANSPORT, anchor: 'public-transit-search' }, ['plan-transit', 'edit-transport']], + ['leg-travel-mode', 'route', 'quick', 4, 3, { slug: MAP, anchor: 'travel-times-between-stops' }, ['plan-transit', 'day-route', 'read-day-plan']], + ['edit-transport', 'pencil', 'quick', 4, 2, { slug: TRANSPORT, anchor: 'in-the-day-plan' }, ['add-transport', 'transports-list', 'bookings-in-plan']], + ['transport-on-map', 'map', 'guide', 5, 3, OVERLAY, ['transports-list', 'day-route'], true], + ], +}) diff --git a/client/src/help/registry.test.ts b/client/src/help/registry.test.ts index 31810af2b..9a530f6c6 100644 --- a/client/src/help/registry.test.ts +++ b/client/src/help/registry.test.ts @@ -13,6 +13,16 @@ import { adminGuides } from './contexts/admin' import { tripGuides } from './contexts/trip' import { tripPlacesGuides } from './contexts/tripPlaces' import { tripDaysGuides } from './contexts/tripDays' +import { tripRoadtripGuides } from './contexts/tripRoadtrip' +import { tripCollabGuides } from './contexts/tripCollab' +import { tripFilesGuides } from './contexts/tripFiles' +import { tripCostsGuides } from './contexts/tripCosts' +import { tripListsGuides } from './contexts/tripLists' +import { tripBookingsGuides } from './contexts/tripBookings' +import { tripTransportsGuides } from './contexts/tripTransports' +import { tripMapGuides } from './contexts/tripMap' +import { tripDayDetailGuides } from './contexts/tripDayDetail' +import { tripPlaceGuides } from './contexts/tripPlace' import { HELP_CONTEXTS, HELP_GUIDES, getHelpContext, getHelpGuide, guidesFor, allHelpGuides, ctxKey, ctxBulletKey, guideKey, guideStepKey, guideTipKey, helpMedia, docsRoute, @@ -85,6 +95,16 @@ describe('help registry: structure', () => { const lists = [ dashboardGuides, vacayGuides, atlasGuides, collectionsGuides, journeyGuides, journalGuides, studioGuides, settingsGuides, adminGuides, tripGuides, tripDaysGuides, tripPlacesGuides, + tripRoadtripGuides, + tripCollabGuides, + tripFilesGuides, + tripCostsGuides, + tripListsGuides, + tripBookingsGuides, + tripTransportsGuides, + tripMapGuides, + tripDayDetailGuides, + tripPlaceGuides, ] const ids = lists.flat().map(g => g.id) const dupes = ids.filter((id, i) => ids.indexOf(id) !== i) diff --git a/client/src/help/registry.ts b/client/src/help/registry.ts index 413f7963b..168bb20a6 100644 --- a/client/src/help/registry.ts +++ b/client/src/help/registry.ts @@ -9,6 +9,16 @@ import { adminContext, adminTabContexts, adminGuides } from './contexts/admin' import { tripContext, tripGuides } from './contexts/trip' import { tripPlacesContext, tripPlacesGuides } from './contexts/tripPlaces' import { tripDaysContext, tripDaysGuides } from './contexts/tripDays' +import { tripRoadtripContext, tripRoadtripGuides } from './contexts/tripRoadtrip' +import { tripCollabContext, tripCollabGuides } from './contexts/tripCollab' +import { tripFilesContext, tripFilesGuides } from './contexts/tripFiles' +import { tripCostsContext, tripCostsGuides } from './contexts/tripCosts' +import { tripListsContext, tripListsGuides } from './contexts/tripLists' +import { tripBookingsContext, tripBookingsGuides } from './contexts/tripBookings' +import { tripTransportsContext, tripTransportsGuides } from './contexts/tripTransports' +import { tripMapContext, tripMapGuides } from './contexts/tripMap' +import { tripDayDetailContext, tripDayDetailGuides } from './contexts/tripDayDetail' +import { tripPlaceContext, tripPlaceGuides } from './contexts/tripPlace' /** * Everything the help center knows, composed from one file per screen under @@ -18,12 +28,24 @@ import { tripDaysContext, tripDaysGuides } from './contexts/tripDays' // The order of the screen switcher: the trip right under My Trips, where it is opened from. const CONTEXT_LIST: HelpContext[] = [ - dashboardContext, tripContext, tripDaysContext, tripPlacesContext, vacayContext, atlasContext, collectionsContext, journeyContext, journalContext, studioContext, + dashboardContext, + // The trip and its own screens, in the order a reader meets them: the three + // columns of the plan, the two panels that open over it, then the tabs in the + // order the tab bar has them, and the drive last because an addon decides it. + tripContext, tripDaysContext, tripPlacesContext, tripMapContext, + tripPlaceContext, tripDayDetailContext, + tripTransportsContext, tripBookingsContext, tripListsContext, tripCostsContext, tripFilesContext, tripCollabContext, + tripRoadtripContext, + vacayContext, atlasContext, collectionsContext, journeyContext, journalContext, studioContext, settingsContext, ...settingsTabContexts, adminContext, ...adminTabContexts, ] const GUIDE_LIST: HelpGuide[] = [ ...dashboardGuides, ...vacayGuides, ...atlasGuides, ...collectionsGuides, ...journeyGuides, ...journalGuides, ...studioGuides, - ...settingsGuides, ...adminGuides, ...tripGuides, ...tripDaysGuides, ...tripPlacesGuides, + ...settingsGuides, ...adminGuides, + ...tripGuides, ...tripDaysGuides, ...tripPlacesGuides, ...tripMapGuides, + ...tripPlaceGuides, ...tripDayDetailGuides, + ...tripTransportsGuides, ...tripBookingsGuides, ...tripListsGuides, ...tripCostsGuides, ...tripFilesGuides, ...tripCollabGuides, + ...tripRoadtripGuides, ] export const HELP_CONTEXTS: ReadonlyMap = new Map(CONTEXT_LIST.map(c => [c.id, c])) diff --git a/shared/src/i18n/ar/help.ts b/shared/src/i18n/ar/help.ts index eb41ee05c..5267d83f8 100644 --- a/shared/src/i18n/ar/help.ts +++ b/shared/src/i18n/ar/help.ts @@ -895,7 +895,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'إعداداتك الشخصية، علامة تبويب لكل موضوع في الشريط الجانبي على اليسار. تسري معظم المفاتيح لحظة تبديلها؛ أما النموذج الذي في أسفله زر «حفظ» فينتظر الضغط عليه. لا شيء هنا يغيّر TREK لدى أي شخص آخر.', 'help.ctx.settings.bullet.1': - 'الشريط الجانبي على اليسار: «العرض» و«Appearance» و«الخريطة» و«الإشعارات» و«التكاملات» و«Offline» و«الحساب». تظهر «الإضافات» بمجرد تثبيت إضافة واحدة، و«حول» على TREK مستضاف ذاتيًا.', + 'الشريط الجانبي على اليسار: «العرض» و«Appearance» و«الخريطة» و«الإشعارات» و«التكاملات» و«Offline» و«الحساب». تظهر «الإضافات» بمجرد تثبيت إضافة واحدة، و«حول» حيثما لم يُزلها من يشغّل TREK.', 'help.ctx.settings.bullet.2': '«العرض» للغة والوحدات والعملة وما يفتح به التطبيق؛ و«Appearance» للسمة والألوان وحجم النص وأدوات لوحة التحكم.', 'help.ctx.settings.bullet.3': @@ -1754,7 +1754,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'المحطة: المقبض على يسارها يسحبها، والصورة تحمل قفلًا لتحسين المسار، ثم الاسم والوصف، وإن كانت مضبوطة، «ملاحظات لهذا اليوم». تظهر شارة وقت تبيّن «البداية» و«النهاية» متى كان للمحطة وقت؛ والسهمان اللذان يظهران في طرفها الأيمن ينقلانها لأعلى أو لأسفل.', 'help.guide.read-day-plan.step.3': - 'حجز في اليوم: تظهر وسيلة النقل بوصفها «المغادرة» أو «الوصول» مع وقتها ومسارها، والحجز المرتبط بمحطة يجعلها «حجز مؤكد» أو «حجز قيد الانتظار». والمفتاح الصغير على وسيلة النقل يعرض مسارها على الخريطة.', + 'حجز في اليوم: الحجز المرتبط بمحطة يجعلها «حجز مؤكد» أو «حجز قيد الانتظار»، وتظهر وسيلة النقل بوصفها «المغادرة» أو «الوصول» مع وقتها ومسارها، ومعها مفتاح صغير يرسم ذلك المسار على الخريطة.', 'help.guide.read-day-plan.step.4': 'بين محطتين يذكر الرابط كم تستغرق المرحلة وكم تبعد، بوسيلة تنقل اليوم؛ انقر عليه لتغيير الوسيلة لتلك المرحلة وحدها.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/br/help.ts b/shared/src/i18n/br/help.ts index 2f5a276c8..f9db6a0df 100644 --- a/shared/src/i18n/br/help.ts +++ b/shared/src/i18n/br/help.ts @@ -982,7 +982,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Suas configurações pessoais, uma aba por assunto na barra lateral à esquerda. A maioria das chaves vale no momento em que você as vira; um formulário com um botão Salvar embaixo espera por ele. Nada aqui muda o TREK de mais ninguém.', 'help.ctx.settings.bullet.1': - 'Barra lateral à esquerda: Exibição, Appearance, Mapa, Notificações, Integrações, Offline e Conta. Plugins aparece assim que um estiver instalado, Sobre em um TREK auto-hospedado.', + 'Barra lateral à esquerda: Exibição, Appearance, Mapa, Notificações, Integrações, Offline e Conta. Plugins aparece assim que um estiver instalado, Sobre onde quer que o admin não o tenha tirado.', 'help.ctx.settings.bullet.2': 'Exibição é idioma, unidades, moeda e com o que o app abre; Appearance é tema, cores, tamanho do texto e os widgets do painel.', 'help.ctx.settings.bullet.3': @@ -1927,7 +1927,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Uma parada: a alça à esquerda a arrasta, a imagem carrega um cadeado para a otimização da rota, depois o nome, a descrição e, se houver, as Notas para este dia. Um selo de horário mostra Início e Fim quando a parada os tem; as setas que aparecem na ponta direita a sobem ou a descem.', 'help.guide.read-day-plan.step.3': - 'Uma reserva no dia: um transporte aparece como Partida ou Chegada com seu horário e trajeto, uma reserva numa parada marca a parada como Reserva confirmada ou Reserva pendente. O pequeno botão num transporte mostra a rota dele no mapa.', + 'Uma reserva no dia: uma reserva numa parada marca a parada como Reserva confirmada ou Reserva pendente, e um transporte aparece como Partida ou Chegada com seu horário e seu trajeto, com um pequeno botão que desenha essa rota no mapa.', 'help.guide.read-day-plan.step.4': 'Entre duas paradas, o conector diz quanto tempo o trecho leva e qual é a distância, no meio de transporte do dia; clique nele para mudar o meio só daquele trecho.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/ca/help.ts b/shared/src/i18n/ca/help.ts index f63b927cb..8e2a78424 100644 --- a/shared/src/i18n/ca/help.ts +++ b/shared/src/i18n/ca/help.ts @@ -999,7 +999,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'La teva configuració personal, una pestanya per tema a la barra lateral de l’esquerra. La majoria d’interruptors s’apliquen en el moment que els canvies; un formulari amb un botó Desar a baix l’espera. Res d’aquí no canvia el TREK de ningú més.', 'help.ctx.settings.bullet.1': - 'Barra lateral esquerra: Pantalla, Aparença, Mapa, Notificacions, Integracions, Fora de línia i Compte. Connectors apareix quan n’hi ha un d’instal·lat, Quant a en un TREK autoallotjat.', + 'Barra lateral esquerra: Pantalla, Aparença, Mapa, Notificacions, Integracions, Fora de línia i Compte. Connectors apareix quan n’hi ha un d’instal·lat, Quant a allà on l’admin no ho hagi tret.', 'help.ctx.settings.bullet.2': 'Pantalla és idioma, unitats, divisa i amb què s’obre l’app; Aparença és tema, colors, mida del text i els ginys del tauler.', 'help.ctx.settings.bullet.3': @@ -1967,7 +1967,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Una parada: la nansa de l’esquerra l’arrossega, la imatge porta un cadenat per a l’optimització de ruta, després el nom, la descripció i, si n’hi ha, les Notes per a aquest dia. Una etiqueta d’hora mostra Inici i Fi quan la parada els té; les fletxes que apareixen al seu extrem dret la pugen o la baixen.', 'help.guide.read-day-plan.step.3': - 'Una reserva del dia: un transport apareix com a Sortida o Arribada amb la seva hora i el seu trajecte, una reserva en una parada la marca com a Reserva confirmada o Reserva pendent. El petit interruptor d’un transport mostra la seva ruta al mapa.', + 'Una reserva del dia: una reserva en una parada la marca com a Reserva confirmada o Reserva pendent, i un transport apareix com a Sortida o Arribada amb la seva hora i el seu trajecte, amb un petit interruptor que dibuixa aquesta ruta al mapa.', 'help.guide.read-day-plan.step.4': 'Entre dues parades, el connector diu quant dura el tram i quina distància fa, en el mitjà de transport del dia; fes-hi clic per canviar el mitjà d’aquell sol tram.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/cs/help.ts b/shared/src/i18n/cs/help.ts index 920f62c98..04d1986ce 100644 --- a/shared/src/i18n/cs/help.ts +++ b/shared/src/i18n/cs/help.ts @@ -954,7 +954,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Vaše osobní nastavení, v postranním panelu vlevo jedna karta na téma. Většina přepínačů se projeví hned, jakmile je přepnete; formulář s tlačítkem Uložit dole na něj čeká. Nic tady nemění TREK nikomu jinému.', 'help.ctx.settings.bullet.1': - 'Postranní panel vlevo: Zobrazení, Appearance, Mapa, Oznámení, Integrace, Offline a Účet. Doplňky se objeví, jakmile je nějaký nainstalovaný, O aplikaci na TREKu, který hostujete sami.', + 'Postranní panel vlevo: Zobrazení, Appearance, Mapa, Oznámení, Integrace, Offline a Účet. Doplňky se objeví, jakmile je nějaký nainstalovaný, O aplikaci všude tam, kde ji správce neodebral.', 'help.ctx.settings.bullet.2': 'Zobrazení je jazyk, jednotky, měna a to, s čím se aplikace otevře; Appearance je motiv, barvy, velikost textu a widgety přehledu.', 'help.ctx.settings.bullet.3': @@ -1873,7 +1873,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Zastávka: úchyt vlevo ji táhne, obrázek nese zámek pro optimalizaci trasy, pak jde název, popis a, pokud jsou nastavené, Poznámky pro tento den. Časový štítek ukazuje Od a Do, když je zastávka má; šipky, které se objeví na jejím pravém konci, s ní posunou nahoru nebo dolů.', 'help.guide.read-day-plan.step.3': - 'Rezervace na dni: doprava se ukáže jako Odlet nebo Přílet se svým časem a trasou, rezervace u zastávky označí zastávku Rezervace potvrzena nebo Rezervace čeká. Malý přepínač na dopravě ukáže její trasu na mapě.', + 'Rezervace na dni: rezervace u zastávky označí zastávku Rezervace potvrzena nebo Rezervace čeká a doprava se ukáže jako Odlet nebo Přílet se svým časem a trasou, s malým přepínačem, který tu trasu vykreslí na mapě.', 'help.guide.read-day-plan.step.4': 'Mezi dvěma zastávkami říká spojnice, jak dlouho úsek trvá a jak je dlouhý, ve způsobu dopravy dne; kliknutím na ni změníte způsob pro ten jeden úsek.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/de/help.ts b/shared/src/i18n/de/help.ts index 0c84471f4..3dd98f2ca 100644 --- a/shared/src/i18n/de/help.ts +++ b/shared/src/i18n/de/help.ts @@ -1006,7 +1006,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Deine persönlichen Einstellungen, ein Tab pro Thema in der Seitenleiste links. Die meisten Schalter greifen in dem Moment, in dem du sie umlegst; ein Formular mit einem Speichern-Button unten wartet darauf. Nichts hier verändert das TREK von jemand anderem.', 'help.ctx.settings.bullet.1': - 'Seitenleiste links: Allgemein, Erscheinungsbild, Karte, Mitteilungen, Integrationen, Offline und Konto. Plugins erscheint, sobald eines installiert ist, Über auf einem selbst gehosteten TREK.', + 'Seitenleiste links: Allgemein, Erscheinungsbild, Karte, Mitteilungen, Integrationen, Offline und Konto. Plugins erscheint, sobald eines installiert ist, Über dort, wo der Admin es nicht entfernt hat.', 'help.ctx.settings.bullet.2': 'Allgemein ist Sprache, Einheiten, Währung und womit die App öffnet; Erscheinungsbild ist Theme, Farben, Textgröße und die Dashboard-Widgets.', 'help.ctx.settings.bullet.3': @@ -1983,7 +1983,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Ein Stopp: der Griff links zieht ihn, das Bild trägt ein Schloss für die Routenoptimierung, dann der Name, die Beschreibung und, wenn gesetzt, die Notizen für diesen Tag. Ein Zeit-Abzeichen zeigt Startzeit und Ende, wenn der Stopp sie hat; die Pfeile, die an seinem rechten Ende erscheinen, schieben ihn nach oben oder nach unten.', 'help.guide.read-day-plan.step.3': - 'Eine Buchung am Tag: ein Transport erscheint als Abflug oder Ankunft mit seiner Zeit und Strecke, eine Reservierung an einem Stopp markiert den Stopp als Reservierung bestätigt oder Reservierung ausstehend. Der kleine Schalter an einem Transport zeigt seine Route auf der Karte.', + 'Eine Buchung am Tag: eine Reservierung an einem Stopp markiert den Stopp als Reservierung bestätigt oder Reservierung ausstehend, und ein Transport erscheint als Abflug oder Ankunft mit seiner Zeit und seiner Strecke, mit einem kleinen Schalter, der diese Route auf der Karte zeichnet.', 'help.guide.read-day-plan.step.4': 'Zwischen zwei Stopps sagt das Verbindungsstück, wie lange die Etappe dauert und wie weit sie ist, im Verkehrsmittel des Tages; klick es an, um das Verkehrsmittel für diese eine Etappe zu ändern.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/en/help.ts b/shared/src/i18n/en/help.ts index 7dd3cafd6..a0e382d86 100644 --- a/shared/src/i18n/en/help.ts +++ b/shared/src/i18n/en/help.ts @@ -1871,9 +1871,9 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'A stop: the grip on the left drags it, the picture carries a lock for route optimization, then the name, the description and, if set, the notes for this day. A time badge shows Start and End when the stop has them; the arrows that appear at its right end move it up or down.', 'help.guide.read-day-plan.step.3': - 'A booking on the day: a transport shows as Departure or Arrival with its time and route, a reservation at a stop marks the stop Reservation confirmed or Reservation pending. The small toggle on a transport shows its route on the map.', + 'A booking on the day: a reservation at a stop marks the stop Reservation confirmed or Reservation pending, and a transport shows as Departure or Arrival with its time and its route, with a small toggle that draws that route on the map.', 'help.guide.read-day-plan.step.4': - 'The connector says how long a leg takes and how far it is, in the day’s travel mode, whether it runs to the next stop or to the night you booked; click it to change the mode for that one leg.', + 'Between two stops the connector says how long the leg takes and how far it is, in the day’s travel mode; click it to change the mode for that one leg.', 'help.guide.read-day-plan.step.5': 'The route bar at the end: Route draws the day’s way on the map, Optimize reorders the stops, the mode buttons pick Driving or Walking, Open in Google Maps and Open in CoMaps open the day there.', 'help.guide.read-day-plan.result': 'Every symbol on the card has a meaning; the guides below change each of them.', @@ -2040,7 +2040,7 @@ const help: TranslationStrings = { 'help.ctx.trip-place.bullet.2': 'The head carries the round picture, the name, the category, the address and the coordinates. Click the picture to use one of your own, double-click the name to rename the place on the spot, and the X on the right closes the card.', 'help.ctx.trip-place.bullet.3': - 'Under it: the stars every traveller gave the place, the price if it has one, the description and the notes, and Notes for this day when the stop carries one.', + 'Under it: the price if it has one, the stars every traveller gave the place, the description and the notes, and Notes for this day when the stop carries one.', 'help.ctx.trip-place.bullet.4': 'Opening Hours, Track color, Track Stats and Files follow, as far as they apply. Files takes anything out of your folders and also lists what hangs on the booking of this stop.', 'help.ctx.trip-place.bullet.5': @@ -2141,7 +2141,7 @@ const help: TranslationStrings = { 'help.guide.place-participants.tip.1': 'Participants only appears with a stop selected, so pick the place inside the day rather than in the places column, and only on a trip with more than one traveller.', 'help.guide.place-participants.tip.2': - 'Nobody chosen means everybody is going. Taking the last one out puts everybody back in.', + 'Nobody chosen means everybody is going, which is why the last traveller left on a stop cannot be taken out.', 'help.guide.place-participants.tip.3': 'A guest, who has no account of their own, can be a participant like anybody else.', // place-booking @@ -2206,7 +2206,7 @@ const help: TranslationStrings = { 'help.guide.place-to-collection.step.3': 'Click the list. The place is in it straight away.', 'help.guide.place-to-collection.step.4': 'Close, and the button in the card reads Saved.', 'help.guide.place-to-collection.result': - 'The place is in your list with its picture, its notes and its stars, ready for the next trip.', + 'The place is in your list with its picture, its notes and its address, ready for the next trip.', 'help.guide.place-to-collection.tip.1': 'The button is only there while the Collections addon is on, which the admin switches on under Addons.', 'help.guide.place-to-collection.tip.2': @@ -2283,7 +2283,7 @@ const help: TranslationStrings = { 'help.guide.day-weather.result': 'The day’s card in the days column carries the same weather in small under its number, so the whole trip can be read at a glance.', 'help.guide.day-weather.tip.1': - 'Degrees and wind follow your choice under Display in Settings: switch to Fahrenheit and the same forecast is read out in °F and mph.', + 'Degrees and wind follow Temperature Unit under General in Settings: pick °F Fahrenheit and the same forecast is read out in °F and mph.', 'help.guide.day-weather.tip.2': 'A day with no located stop and no hotel to wake up in shows no weather at all: the forecast is always for a place, never for the trip.', 'help.guide.day-weather.tip.3': @@ -2332,7 +2332,7 @@ const help: TranslationStrings = { 'help.guide.edit-accommodation.step.2': 'The pencil at its right opens the stay again. The popup now reads Edit accommodation.', 'help.guide.edit-accommodation.step.3': - 'Change what you need: the days it covers, Check-in, Until, Check-out, Confirmation, or the property itself.', + 'Correct the row of fields: Check-in, Until, Check-out and Confirmation. The days above it and the property below it can be changed here too.', 'help.guide.edit-accommodation.step.4': 'Click Save.', 'help.guide.edit-accommodation.step.5': 'The X next to the pencil ends the stay. It asks nothing, and the Hotel booking that belongs to it goes with it.', @@ -2365,7 +2365,7 @@ const help: TranslationStrings = { 'help.ctx.trip-map.summary': 'The middle of the plan: every place of the trip as a pin, the routes that join them, and the switches along the edges of the map for satellite, for the whole trip at once and for the places around the part of town you are looking at.', 'help.ctx.trip-map.bullet.1': - 'A pin is a place: its own photo when it has one, otherwise its category colour with the category icon. Rest the pointer on one for a card with its name, its rating, its category and its address.', + 'A pin is a place: its own photo when it has one, otherwise its category colour with the category icon. Rest the pointer on one for a card with its name, its rating, its category and its address, and drag it onto a day card to plan it there.', 'help.ctx.trip-map.bullet.2': 'Pins too close together to tell apart fold into one dark bubble with a count. Click the bubble and the map zooms to what is inside.', 'help.ctx.trip-map.bullet.3': @@ -2382,15 +2382,15 @@ const help: TranslationStrings = { 'help.guide.map-markers.title': 'Read the map', 'help.guide.map-markers.goal': 'Know what every pin, badge and bubble on the map is telling you.', 'help.guide.map-markers.step.1': - 'The map holds every place of the trip. Where pins sit too close together to tell apart, they fold into one dark bubble carrying the number of places inside it.', + 'The map holds every place of the trip. Where pins sit too close together to tell apart they fold into one dark bubble carrying the number inside it; click the bubble and the map zooms to what was in it, or, at the deepest zoom, fans the pins apart.', 'help.guide.map-markers.step.2': - 'Click the bubble. The map zooms to what was inside and the pins separate; at the deepest zoom it fans them apart instead of zooming further.', - 'help.guide.map-markers.step.3': 'A pin is the place’s own photo when it has one, otherwise its category colour with the category icon. Rest the pointer on one and a card gives its name, its rating, its category and its address.', - 'help.guide.map-markers.step.4': + 'help.guide.map-markers.step.3': 'Click a pin and the place opens under the map: its coordinates, its rating, its files, and Add to Day, Save to Collection, Navigation, Edit and Delete. Click an empty piece of the map to let it go again.', - 'help.guide.map-markers.step.5': + 'help.guide.map-markers.step.4': 'Open a day in the days column and its stops get numbered: the small white badge at a pin’s corner is that stop’s place in the day. A place planned on two days carries both numbers, joined by ·. Without a day open there are no numbers, and the corner carries the rating instead.', + 'help.guide.map-markers.step.5': + 'Drag a pin off the map onto a day card in the days column and the place is planned on that day, exactly as dragging its row out of the places list would.', 'help.guide.map-markers.result': 'Nothing on the trip has changed: the map is a view of it, and every pin says which place, which day and in which order.', 'help.guide.map-markers.tip.1': @@ -2398,7 +2398,7 @@ const help: TranslationStrings = { 'help.guide.map-markers.tip.2': 'The filter above the places list decides what the map draws as well: pick Unplanned and only the places still without a day are left on it.', 'help.guide.map-markers.tip.3': - 'There are no zoom buttons on this map: the wheel zooms, a double click zooms in a step, and dragging moves it.', + 'There are no zoom buttons on this map: the wheel zooms, a double click zooms in a step, and dragging the map itself moves it.', // map-nearby-places 'help.guide.map-nearby-places.title': 'Find places around you on the map', 'help.guide.map-nearby-places.goal': @@ -2444,11 +2444,10 @@ const help: TranslationStrings = { 'help.guide.map-satellite.title': 'Switch to satellite', 'help.guide.map-satellite.goal': 'Swap the drawn map for aerial imagery, and back.', 'help.guide.map-satellite.step.1': - 'The round button at the bottom left of the map is the base layer switch. Its icon always shows the layer it would move to, and hovering it says which: Switch to satellite view.', + 'The round button at the bottom left of the map is the base layer switch. Its icon always shows the layer it would move to, and hovering it says which: Switch to satellite view. Click it.', 'help.guide.map-satellite.step.2': - 'Click it. The map becomes aerial imagery, deep enough to make out a single building, and without a key of your own.', - 'help.guide.map-satellite.step.3': - 'Everything TREK draws stays on top of it: the pins, the day’s route, the tracks and the booking routes. Click the button again, now reading Switch to map view, to go back.', + 'The map is aerial imagery now, deep enough to make out a single building and without a key of your own. Everything TREK draws stays on top of it: the pins, the day’s route, the tracks and the booking routes.', + 'help.guide.map-satellite.step.3': 'The button now reads Switch to map view. Click it to go back to the drawn map.', 'help.guide.map-satellite.result': 'The map is drawn again, and the layer you left it on is remembered on your account.', 'help.guide.map-satellite.tip.1': @@ -2459,13 +2458,14 @@ const help: TranslationStrings = { 'help.guide.map-whole-trip.title': 'See the whole trip and its distances', 'help.guide.map-whole-trip.goal': 'Swap the one open day for every travel day of the trip, and read how far each one goes.', - 'help.guide.map-whole-trip.step.1': 'The round Show whole trip button sits at the bottom right of the map.', + 'help.guide.map-whole-trip.step.1': + 'The round Show whole trip button sits at the bottom right of the map. Click it and every travel day of the trip is drawn at once, each in its own colour over a white casing, so neighbouring days stay apart.', 'help.guide.map-whole-trip.step.2': - 'Click it. Every travel day of the trip is drawn at once, each in its own colour over a white casing, so neighbouring days stay apart.', - 'help.guide.map-whole-trip.step.3': 'The card above the button lists those days: a colour dot, the day’s name, an icon for each way you travel it, and the distance it covers. Total distance is at the top.', + 'help.guide.map-whole-trip.step.3': + 'Click a day in the card to select it, the same as picking it in the days column: the map frames that day, and its stops get their numbers back.', 'help.guide.map-whole-trip.step.4': - 'Click a day in the card to select it, the same as picking it in the days column. Click the button again, now reading Hide whole trip, to go back to the one day.', + 'The button now reads Hide whole trip. Press it to drop back to the one open day.', 'help.guide.map-whole-trip.result': 'Every travel day is drawn in its own colour, and the card says what each one covers and what the trip comes to.', 'help.guide.map-whole-trip.tip.1': @@ -2481,9 +2481,9 @@ const help: TranslationStrings = { 'help.guide.map-booking-routes.step.1': 'Booking routes are off until you ask for one. On a booking’s row in the days column sits a small route icon: Show booking routes.', 'help.guide.map-booking-routes.step.2': - 'Click it. The booking appears on the map: a flight as a great circle arc, a drive along the real roads, a train as the chain of its stations. Confirmed is drawn solid, pending dashed.', + 'Click it and the booking appears on the map: a flight as a great circle arc, a drive along the real roads, a train as the chain of its stations. Confirmed is drawn solid, pending dashed, and the ends of the route are blue pills with the transport’s icon.', 'help.guide.map-booking-routes.step.3': - 'The ends of the route are blue pills with the transport’s icon. Click one to open the booking behind it, with its times, its reference and where it starts; Close puts it away again.', + 'Click an end pill and the booking behind it opens, with its times, its reference and where it starts. Close puts it away again.', 'help.guide.map-booking-routes.step.4': 'The route icon in the toolbar above the days does the whole trip at once: Show all booking routes draws every booking that has one.', 'help.guide.map-booking-routes.step.5': @@ -2561,7 +2561,7 @@ const help: TranslationStrings = { 'help.guide.plan-transit.step.1': 'In the day header, click the tram button, Public transit. The search opens for that day.', 'help.guide.plan-transit.step.2': - 'From and To take a stop or a station. With the box still empty the day’s own stops are offered; typing two letters searches the timetable’s stations instead. Swap between the two boxes turns the connection round.', + 'From and To take a stop or a station. With the box still empty the day’s own stops and the trip’s accommodations are offered; typing two letters searches the timetable’s stations instead. Swap between the two boxes turns the connection round.', 'help.guide.plan-transit.step.3': 'Depart or Arrive with a time says when you want to travel, and Best route, Fewer transfers or Less walking says how the answers should be ordered.', 'help.guide.plan-transit.step.4': @@ -2637,7 +2637,7 @@ const help: TranslationStrings = { 'help.guide.transport-on-map.step.2': 'The route is drawn on the map, with a pill marker at each end carrying the transport’s icon.', 'help.guide.transport-on-map.step.3': - 'Click an end marker to read the booking without leaving the map: the times, the airline and flight number, the Booking Code and the notes. Close puts the sheet away.', + 'Click an end marker to read the booking without leaving the map: the times, the airline and flight number, the Booking Code and the address. Close puts the sheet away.', 'help.guide.transport-on-map.step.4': 'The route icon in the toolbar above the days does the whole trip at once: Show all booking routes, and Hide all booking routes to clear them again.', 'help.guide.transport-on-map.step.5': @@ -2762,7 +2762,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.step.1': 'Open the booking and go to the foot of the form. Under Costs stands Create expense, with the note Saves the booking, then opens the Costs editor.', 'help.guide.booking-cost.step.2': - 'Click Create expense. The booking is saved and the Costs editor opens on top of it.', + 'Click Create expense. The booking is saved, its form closes and the Costs editor opens.', 'help.guide.booking-cost.step.3': 'What was it for? is already the booking’s title. Put in the Total amount and check the Currency and the Day.', 'help.guide.booking-cost.step.4': @@ -2856,7 +2856,7 @@ const help: TranslationStrings = { 'help.ctx.trip-lists.bullet.5': 'Apply template, Import and Save as template fill or keep a list without typing it out, and the red button beside them names how many items are ticked and clears them away.', 'help.ctx.trip-lists.bullet.6': - 'To-Do has a sidebar of its own: the progress card, the filters All, My Tasks, Overdue and Done, Sort by Priority or Due date, and one row per list. A click on a task opens it in the pane on the right, and Add new task opens the same pane empty.', + 'To-Do has a sidebar of its own: the progress card, the filters All, My Tasks, Overdue and Done, Sort by Priority or Due date, and one row per list. A click on a task opens it in the pane on the right, and Add new task opens the New task form over the middle of the screen.', // packing-categories 'help.guide.packing-categories.title': 'Build the packing list', 'help.guide.packing-categories.goal': @@ -3013,7 +3013,7 @@ const help: TranslationStrings = { 'help.ctx.trip-costs.summary': 'The trip’s money: every expense as a dated ledger, who put it down and who owes for it, in whatever currency the receipt was in, and, in the right-hand column, who has to pay whom to make it even again.', 'help.ctx.trip-costs.bullet.1': - 'Four cards at the top: You owe and You’re owed are your own side of the settlement, Outstanding amount is what is recorded but has no payer yet, and Total trip spend adds everything up with your share and what you paid under it.', + "Four cards at the top: You owe and You're owed are your own side of the settlement, Outstanding amount is what is recorded but has no payer yet, and Total trip spend adds everything up with your share and what you paid under it.", 'help.ctx.trip-costs.bullet.2': 'Add expense at the top right opens the editor; Settle up beside it records every open transfer at once.', 'help.ctx.trip-costs.bullet.3': @@ -3064,7 +3064,7 @@ const help: TranslationStrings = { 'help.guide.expense-payers.tip.1': 'Who paid? and Split are independent: you can pay for a dinner you were not at, and be split into one you did not pay for.', 'help.guide.expense-payers.tip.2': - 'With several payers the amounts have to add up to the total. Include one more and the others rearrange themselves around it; while they do not match, the editor says Payer amounts must add up to the total and refuses to save.', + 'With several payers the amounts have to add up to the total. Include one more and the others rearrange themselves around it; while they do not match, the editor says what they have to add up to and refuses to save.', 'help.guide.expense-payers.tip.3': 'Removing a payer does not remove the expense: the amount stays in Total trip spend and the row becomes Unfinished.', // split-expense @@ -3181,7 +3181,7 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.3': 'Click Create expense. The booking is saved, the form closes, and the Costs editor opens with the booking’s title as the name and its type already matched to a category.', 'help.guide.expense-from-booking.step.4': - 'Fill in the amount, who paid and the split as for any expense, and save. Reopening the booking now shows it under Linked expense, with a pencil to edit it and a bin to remove it.', + 'Fill in the amount and its currency, who paid and the split as for any expense, and save. Reopening the booking now shows it under Linked expense, with a pencil to edit it and a bin to remove it.', 'help.guide.expense-from-booking.result': 'The booking carries its cost, and the expense is an ordinary row on the Costs tab, with a payer, a split and a currency like any other.', 'help.guide.expense-from-booking.tip.1': @@ -3212,9 +3212,9 @@ const help: TranslationStrings = { 'help.guide.files-upload.step.1': 'Open the trip and click Files in the tab bar. The trip’s documents are listed there, with the upload box above them.', 'help.guide.files-upload.step.2': - 'Click Drop files here and pick one or several files. They are uploaded one after the other and the box reads Uploading… while it runs. The line under the box says which types this TREK takes, and that a file may be 50 MB at most.', + 'Click Drop files here and pick one or several files. They are uploaded one after the other and the box reads Uploading... while it runs. The line under the box says which types this TREK takes, and that a file may be 50 MB at most.', 'help.guide.files-upload.step.3': - 'As soon as the last file is up, Assign File opens for it by itself. Add a note… gives the file a line of its own, and the lists under it tie it to a place or a booking. Close it with the ×; nothing is lost by closing it.', + 'As soon as the last file is up, Assign File opens for it by itself. Add a note... gives the file a line of its own, and the lists under it tie it to a place or a booking. Close it with the ×; nothing is lost by closing it.', 'help.guide.files-upload.step.4': 'The new files stand at the top of the list. A row shows who uploaded it, the name, the size and the date; a picture gets a thumbnail, every other file its type.', 'help.guide.files-upload.result': @@ -3224,14 +3224,14 @@ const help: TranslationStrings = { 'help.guide.files-upload.tip.2': 'A picture on the clipboard goes into the list with Ctrl+V, so a screenshot of a booking never has to be saved first.', 'help.guide.files-upload.tip.3': - 'Uploading needs the right to upload files; without it the box is not there at all. A type that is not on the list, or a file over 50 MB, is refused with a message and nothing is uploaded.', + 'Uploading needs the right to upload files; without it the box is not there at all. A type that is not on the list is refused with a message and nothing is uploaded. A file over 50 MB is dropped by the box itself, before anything is sent.', // files-link 'help.guide.files-link.title': 'Tie a document to a place or a booking', 'help.guide.files-link.goal': 'Make the ticket findable from the day it belongs to, not only from this list.', 'help.guide.files-link.step.1': 'Click Assign, the pencil at the end of the row. Assign File opens, named after the file.', 'help.guide.files-link.step.2': - 'Under Note, Add a note… takes one line, which then stands under the file’s name in the list. It is saved the moment you leave the box.', + 'Under Note, Add a note... takes one line, which then stands under the file’s name in the list. It is saved the moment you leave the box.', 'help.guide.files-link.step.3': 'Under Place stand the trip’s places, grouped by the day they are on, with Unassigned at the end for the ones on no day. Click one and it gets a tick.', 'help.guide.files-link.step.4': @@ -3239,7 +3239,7 @@ const help: TranslationStrings = { 'help.guide.files-link.step.5': 'Close with the ×. There is no save button here: every click was written as you made it.', 'help.guide.files-link.result': - 'The row carries the note and one badge per link, Day Plan and the place’s name, Transport and the flight’s name, and the document hangs on the place and on the booking as well.', + 'The row carries the note and one badge per link, Day Plan and the place’s name, Transport and the flight’s name, and the document hangs on the place and on the flight as well.', 'help.guide.files-link.tip.1': 'A file can hold several links at once, so the same confirmation belongs to the hotel and to the night it covers.', 'help.guide.files-link.tip.2': 'Clicking a ticked entry again takes that link away; the file itself stays.', @@ -3268,7 +3268,7 @@ const help: TranslationStrings = { 'The tabs above the list are All, PDFs, Images and Documents, each with the number of files behind it.', 'help.guide.files-filter.step.2': 'Click PDFs: the list keeps the PDF files and nothing else.', 'help.guide.files-filter.step.3': - 'Two more tabs come and go with what is in the trip: a star as soon as a file is starred, and Collab Notes as soon as a note in the Collab tab carries an attachment.', + 'Two more tabs come and go with what is in the trip. Click Collab Notes, which is there as soon as a note in the Collab tab carries an attachment: the list keeps those files and nothing else. A star joins the row the same way, as soon as a file is starred.', 'help.guide.files-filter.step.4': 'All brings the whole list back.', 'help.guide.files-filter.result': 'The list shows only what the tab names, and the count on each tab says how many that is.', @@ -3333,7 +3333,7 @@ const help: TranslationStrings = { 'help.ctx.trip-collab.bullet.5': 'Polls decide things. New Poll asks a question with at least two options; a click on an option is your vote, Close ends the voting and Delete removes the poll.', 'help.ctx.trip-collab.bullet.6': - 'What’s Next lists the next eight stops of the trip that are still ahead, with their times and the people on them. It only reads the day plan; the times are set there.', + 'What’s Next lists the stops of the trip that are still ahead, up to eight of them, with their times and the people on them. It only reads the day plan; the times are set there.', // write-note 'help.guide.write-note.title': 'Write a shared note', 'help.guide.write-note.goal': @@ -3421,7 +3421,8 @@ const help: TranslationStrings = { 'Close, the lock in a poll’s corner, ends the voting. The options stop taking clicks.', 'help.guide.close-poll.step.2': 'A closed poll sinks under the Closed heading at the bottom of the panel, wears a Closed badge and shows everybody the result, whether they voted or not. The winning option is tinted green.', - 'help.guide.close-poll.step.3': 'Delete beside it removes the poll. Nothing asks twice, and the votes go with it.', + 'help.guide.close-poll.step.3': + 'Delete, the bin in the same corner, removes the poll. Nothing asks twice, and the votes go with it.', 'help.guide.close-poll.result': 'The poll is gone from every member’s panel. One you only closed stays readable at the bottom, with its result.', 'help.guide.close-poll.tip.1': @@ -3432,7 +3433,7 @@ const help: TranslationStrings = { 'help.guide.whats-next.title': 'Read What’s Next', 'help.guide.whats-next.goal': 'See what the group is doing next without opening the plan.', 'help.guide.whats-next.step.1': - 'The panel lists the next eight stops of the trip that are still ahead, in time order, under a heading per day: Today, Tomorrow or the date.', + 'The panel lists the stops of the trip that are still ahead, up to eight of them, in time order, under a heading per day: Today, Tomorrow or the date.', 'help.guide.whats-next.step.2': 'On the left of a row stands its time: the start, to, and the end when the stop has one, or TBD when no time is set on it yet.', 'help.guide.whats-next.step.3': @@ -3524,11 +3525,11 @@ const help: TranslationStrings = { 'help.guide.roadtrip-stops.step.6': 'Set the length with the slider, with the minus and plus buttons or with one of the presets, watch what Arrive and Leave do, then click Save.', 'help.guide.roadtrip-stops.result': - 'The stop wears its kind’s icon instead of a number, and every arrival after a stay has moved with it.', + 'The stop you timed carries the hour on its Stay badge and every arrival after it has moved with it, and the one you sent to a kind and back is a numbered destination again.', 'help.guide.roadtrip-stops.tip.1': 'A stay belongs to the place, not to one visit: a place planned on two days is stood at just as long on both.', 'help.guide.roadtrip-stops.tip.2': - 'Stops on the way show under Days as well. Show in Days too, under Service stops in the Driving settings, keeps them in Road trip only.', + 'Stops on the way show under Days as well. Turning Show in Days too off, under Service stops in the Driving settings, keeps them in Road trip only.', 'help.guide.roadtrip-stops.tip.3': 'No stay, in the same dialog, takes the time away again.', // roadtrip-corridor 'help.guide.roadtrip-corridor.title': 'Find fuel, food and a bed along the route', @@ -3579,7 +3580,7 @@ const help: TranslationStrings = { 'help.guide.roadtrip-alternatives.step.2': 'Ways to drive this leg opens over the map, one entry per road, each drawn on the map in its own colour.', 'help.guide.roadtrip-alternatives.step.3': - 'Hover an entry to light that road up. Current and Fastest say which is which, and the others say how much quicker or slower they are.', + 'Hover an entry to light that road up. Current is the road being driven and Fastest the quickest; the others say how much slower they are, or which road class they leave out.', 'help.guide.roadtrip-alternatives.step.4': 'Click an entry to drive that way, or Close to keep the road you are on.', 'help.guide.roadtrip-alternatives.result': 'The leg drives the road you chose, and the rail’s distance and the arrivals after it change with it.', @@ -3655,15 +3656,15 @@ const help: TranslationStrings = { 'help.guide.roadtrip-track.goal': 'Put a day’s drive onto a scenic route you imported as a GPX or KML track.', 'help.guide.roadtrip-track.step.1': 'Click the Track badge in a day’s header. The dialog opens on that day.', 'help.guide.roadtrip-track.step.2': - 'Pick a track. Each one says how long it is and how far it lies from this day, nearest first.', + 'Pick a track. Each one says how long it is and whether it runs along this day or how far off it lies, nearest first.', 'help.guide.roadtrip-track.step.3': 'Click Follow this track. TREK drops via points where the drive strays furthest from the track, and routes again, round after round.', 'help.guide.roadtrip-track.step.4': - 'It says how many via points it placed and how close the drive now stays. The button beside it drops those via points again and gives the day back to the router; closing the dialog keeps the track.', + 'It says how many via points it placed and how close the drive now stays. The button under it drops those via points again and gives the day back to the router; closing the dialog keeps the track.', 'help.guide.roadtrip-track.result': - 'The day’s drive follows the track instead of the road the router picked, and the Track badge says which track it is following.', + 'The day’s drive follows the track instead of the road the router picked, and its Track badge is lit and names that track when you point at it.', 'help.guide.roadtrip-track.tip.1': - 'Import the file under Days with Import file, with its tracks or paths ticked. Until a track is in the trip the badge has nothing to offer.', + 'Import the file under Days with Import file, with Routes or Tracks ticked. Until the trip holds one, no day carries the badge.', 'help.guide.roadtrip-track.tip.2': 'Following a track replaces the via points the day’s legs already had, so shape a leg by hand after the track, not before.', }; diff --git a/shared/src/i18n/es/help.ts b/shared/src/i18n/es/help.ts index d86f90815..94022fd4e 100644 --- a/shared/src/i18n/es/help.ts +++ b/shared/src/i18n/es/help.ts @@ -1001,7 +1001,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Tus ajustes personales, una pestaña por tema en la barra lateral de la izquierda. La mayoría de los interruptores se aplican en cuanto los cambias; un formulario con un botón Guardar abajo espera a que lo pulses. Nada de aquí cambia el TREK de nadie más.', 'help.ctx.settings.bullet.1': - 'Barra lateral izquierda: Pantalla, Appearance, Mapa, Notificaciones, Integraciones, Offline y Cuenta. Complementos aparece en cuanto hay uno instalado, Acerca de en un TREK autoalojado.', + 'Barra lateral izquierda: Pantalla, Appearance, Mapa, Notificaciones, Integraciones, Offline y Cuenta. Complementos aparece en cuanto hay uno instalado, Acerca de allí donde el admin no lo haya quitado.', 'help.ctx.settings.bullet.2': 'Pantalla es idioma, unidades, moneda y con qué se abre la app; Appearance es tema, colores, tamaño del texto y los widgets del panel.', 'help.ctx.settings.bullet.3': @@ -1975,7 +1975,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Una parada: el asidero de la izquierda la arrastra, la imagen lleva un candado para la optimización de ruta, luego el nombre, la descripción y, si las hay, las Notas para este día. Una insignia de hora muestra Inicio y Fin cuando la parada los tiene; las flechas que aparecen en su extremo derecho la suben o la bajan.', 'help.guide.read-day-plan.step.3': - 'Una reserva del día: un transporte aparece como Salida o Llegada con su hora y su trayecto, una reserva en una parada la marca como Reserva confirmada o Reserva pendiente. El pequeño interruptor de un transporte muestra su ruta en el mapa.', + 'Una reserva del día: una reserva en una parada la marca como Reserva confirmada o Reserva pendiente, y un transporte aparece como Salida o Llegada con su hora y su trayecto, con un pequeño interruptor que dibuja esa ruta en el mapa.', 'help.guide.read-day-plan.step.4': 'Entre dos paradas el conector dice cuánto dura el tramo y qué distancia tiene, en el medio de transporte del día; haz clic en él para cambiar el medio de ese único tramo.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/fr/help.ts b/shared/src/i18n/fr/help.ts index 23ba111ca..2782159bf 100644 --- a/shared/src/i18n/fr/help.ts +++ b/shared/src/i18n/fr/help.ts @@ -1018,7 +1018,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Vos réglages personnels, un onglet par sujet dans la barre latérale à gauche. La plupart des interrupteurs s’appliquent dès que vous les basculez ; un formulaire avec un bouton Enregistrer en bas l’attend. Rien ici ne change le TREK de quelqu’un d’autre.', 'help.ctx.settings.bullet.1': - 'Barre latérale à gauche : Affichage, Appearance, Carte, Notifications, Intégrations, Offline et Compte. Modules apparaît dès qu’un module est installé, À propos sur un TREK auto-hébergé.', + 'Barre latérale à gauche : Affichage, Appearance, Carte, Notifications, Intégrations, Offline et Compte. Modules apparaît dès qu’un module est installé, À propos partout où l’admin ne l’a pas retiré.', 'help.ctx.settings.bullet.2': 'Affichage, c’est la langue, les unités, la devise et ce sur quoi l’application s’ouvre ; Appearance, c’est le thème, les couleurs, la taille du texte et les widgets du tableau de bord.', 'help.ctx.settings.bullet.3': @@ -2020,7 +2020,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Une étape : la poignée à gauche la fait glisser, l’image porte un cadenas pour l’optimisation de l’itinéraire, puis le nom, la description et, si elles sont remplies, les Notes pour ce jour. Un badge horaire affiche Début et Fin quand l’étape en a ; les flèches qui apparaissent à son extrémité droite la montent ou la descendent.', 'help.guide.read-day-plan.step.3': - 'Une réservation du jour : un transport s’affiche comme Départ ou Arrivée avec son heure et son trajet, une réservation liée à une étape marque celle-ci Réservation confirmée ou Réservation en attente. Le petit interrupteur sur un transport montre son itinéraire sur la carte.', + 'Une réservation du jour : une réservation liée à une étape marque celle-ci Réservation confirmée ou Réservation en attente, et un transport s’affiche comme Départ ou Arrivée avec son heure et son trajet, avec un petit interrupteur qui trace cet itinéraire sur la carte.', 'help.guide.read-day-plan.step.4': 'Entre deux étapes, le connecteur indique combien de temps dure le tronçon et quelle distance il fait, dans le mode de transport du jour ; cliquez dessus pour changer le mode de ce seul tronçon.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/gr/help.ts b/shared/src/i18n/gr/help.ts index a741c443e..d6cd32cc4 100644 --- a/shared/src/i18n/gr/help.ts +++ b/shared/src/i18n/gr/help.ts @@ -1025,7 +1025,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Οι προσωπικές σας ρυθμίσεις, μία καρτέλα ανά θέμα στην πλαϊνή στήλη αριστερά. Οι περισσότεροι διακόπτες εφαρμόζονται τη στιγμή που τους γυρίζετε· μια φόρμα με κουμπί Αποθήκευση στο κάτω μέρος το περιμένει. Τίποτα εδώ δεν αλλάζει το TREK κανενός άλλου.', 'help.ctx.settings.bullet.1': - 'Πλαϊνή στήλη αριστερά: Εμφάνιση, Appearance, Χάρτης, Ειδοποιήσεις, Ενσωματώσεις, Εκτός σύνδεσης και Λογαριασμός. Τα Πρόσθετα εμφανίζονται μόλις εγκατασταθεί ένα, το Σχετικά σε TREK που φιλοξενείτε μόνοι σας.', + 'Πλαϊνή στήλη αριστερά: Εμφάνιση, Appearance, Χάρτης, Ειδοποιήσεις, Ενσωματώσεις, Εκτός σύνδεσης και Λογαριασμός. Τα Πρόσθετα εμφανίζονται μόλις εγκατασταθεί ένα, το Σχετικά όπου ο διαχειριστής δεν το έχει αφαιρέσει.', 'help.ctx.settings.bullet.2': 'Η Εμφάνιση είναι γλώσσα, μονάδες, νόμισμα και με τι ανοίγει η εφαρμογή· το Appearance είναι θέμα, χρώματα, μέγεθος κειμένου και τα widgets του πίνακα.', 'help.ctx.settings.bullet.3': @@ -2028,7 +2028,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Μια στάση: η λαβή αριστερά τη σύρει, η εικόνα κουβαλά ένα λουκέτο για τη βελτιστοποίηση διαδρομής, μετά έρχονται το όνομα, η περιγραφή και, αν έχουν οριστεί, οι Σημειώσεις για αυτή την ημέρα. Ένα σήμα ώρας δείχνει Έναρξη και Λήξη όταν η στάση τις έχει· τα βέλη που εμφανίζονται στο δεξί της άκρο τη μετακινούν πάνω ή κάτω.', 'help.guide.read-day-plan.step.3': - 'Μια κράτηση στην ημέρα: μια μεταφορά εμφανίζεται ως Αναχώρηση ή Άφιξη με την ώρα και τη διαδρομή της, μια κράτηση σε μια στάση σημειώνει τη στάση Κράτηση επιβεβαιωμένη ή Κράτηση σε εκκρεμότητα. Ο μικρός διακόπτης πάνω σε μια μεταφορά δείχνει τη διαδρομή της στον χάρτη.', + 'Μια κράτηση στην ημέρα: μια κράτηση σε μια στάση σημειώνει τη στάση Κράτηση επιβεβαιωμένη ή Κράτηση σε εκκρεμότητα, και μια μεταφορά εμφανίζεται ως Αναχώρηση ή Άφιξη με την ώρα και τη διαδρομή της, με έναν μικρό διακόπτη που σχεδιάζει αυτή τη διαδρομή στον χάρτη.', 'help.guide.read-day-plan.step.4': 'Ανάμεσα σε δύο στάσεις ο συνδετήρας λέει πόσο κρατά το σκέλος και πόσο μακριά είναι, στο μέσο μεταφοράς της ημέρας· κάντε κλικ πάνω του για να αλλάξετε το μέσο για εκείνο μόνο το σκέλος.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/hu/help.ts b/shared/src/i18n/hu/help.ts index 685b7eb0b..8c9e5f21f 100644 --- a/shared/src/i18n/hu/help.ts +++ b/shared/src/i18n/hu/help.ts @@ -997,7 +997,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'A személyes beállításaid, a bal oldali sávban témánként egy fül. A legtöbb kapcsoló abban a pillanatban érvényes, ahogy átbillented; az alján Mentés gombos űrlap arra vár. Itt semmi sem változtat más TREK-jén.', 'help.ctx.settings.bullet.1': - 'Bal oldali sáv: Megjelenés, Appearance, Térkép, Értesítések, Integrációk, Offline és Fiók. A Bővítmények akkor jelenik meg, ha egy telepítve van, a Névjegy saját üzemeltetésű TREK-en.', + 'Bal oldali sáv: Megjelenés, Appearance, Térkép, Értesítések, Integrációk, Offline és Fiók. A Bővítmények akkor jelenik meg, ha egy telepítve van, a Névjegy mindenütt, ahol az admin nem távolította el.', 'help.ctx.settings.bullet.2': 'A Megjelenés a nyelv, a mértékegységek, a pénznem és az, amivel az alkalmazás megnyílik; az Appearance a téma, a színek, a szövegméret és az irányítópult widgetjei.', 'help.ctx.settings.bullet.3': @@ -1972,7 +1972,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Egy megálló: a bal oldali fogantyú húzza, a kép lakatot visel az útvonal-optimalizáláshoz, majd jön a név, a leírás és, ha be van állítva, a Jegyzetek erre a napra. Egy időjelvény a Kezdés és a Befejezés értéket mutatja, ha a megállónak van ilyen; a jobb szélén megjelenő nyilak felfelé vagy lefelé mozgatják.', 'help.guide.read-day-plan.step.3': - 'Egy foglalás a napon: egy közlekedés Indulás vagy Érkezés formában jelenik meg az időpontjával és az útvonalával, egy megállóhoz kötött foglalás pedig Foglalás megerősítve vagy Foglalás függőben jelzéssel látja el a megállót. A közlekedésen lévő kis kapcsoló megmutatja az útvonalát a térképen.', + 'Egy foglalás a napon: egy megállóhoz kötött foglalás Foglalás megerősítve vagy Foglalás függőben jelzéssel látja el a megállót, egy közlekedés pedig Indulás vagy Érkezés formában jelenik meg az időpontjával és az útvonalával, és a rajta lévő kis kapcsoló felrajzolja azt az útvonalat a térképen.', 'help.guide.read-day-plan.step.4': 'Két megálló között az összekötő megmondja, mennyi ideig tart a szakasz és milyen hosszú, a nap közlekedési módjában; kattints rá, ha csak annál az egy szakasznál akarod a módot megváltoztatni.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/id/help.ts b/shared/src/i18n/id/help.ts index 267d4f3d5..ce8ec5826 100644 --- a/shared/src/i18n/id/help.ts +++ b/shared/src/i18n/id/help.ts @@ -994,7 +994,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Pengaturan pribadi Anda, satu tab per topik di bilah samping kiri. Sebagian besar sakelar langsung berlaku begitu Anda mengubahnya; formulir dengan tombol “Simpan” di bawahnya menunggu tombol itu ditekan. Tidak ada yang di sini mengubah TREK milik orang lain.', 'help.ctx.settings.bullet.1': - 'Bilah samping kiri: “Tampilan”, “Appearance”, “Peta”, “Notifikasi”, “Integrasi”, “Offline”, dan “Akun”. “Plugin” muncul begitu ada satu yang terpasang, “Tentang” pada TREK yang dihosting sendiri.', + 'Bilah samping kiri: “Tampilan”, “Appearance”, “Peta”, “Notifikasi”, “Integrasi”, “Offline”, dan “Akun”. “Plugin” muncul begitu ada satu yang terpasang, “Tentang” di mana pun operatornya tidak menghapusnya.', 'help.ctx.settings.bullet.2': '“Tampilan” berisi bahasa, satuan, mata uang, dan apa yang dibuka aplikasi saat mulai; “Appearance” berisi tema, warna, ukuran teks, dan widget dasbor.', 'help.ctx.settings.bullet.3': @@ -1965,7 +1965,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Sebuah perhentian: pegangan di kiri menyeretnya, gambarnya membawa kunci untuk optimasi rute, lalu nama, deskripsi dan, jika diisi, “Catatan untuk hari ini”. Sebuah lencana waktu menampilkan “Mulai” dan “Selesai” bila perhentian itu punya keduanya; panah yang muncul di ujung kanannya memindahkannya ke atas atau ke bawah.', 'help.guide.read-day-plan.step.3': - 'Sebuah pemesanan pada hari itu: transportasi tampil sebagai “Keberangkatan” atau “Kedatangan” dengan waktu dan rutenya, sebuah reservasi pada perhentian menandai perhentian itu “Reservasi dikonfirmasi” atau “Reservasi tertunda”. Sakelar kecil pada transportasi menampilkan rutenya di peta.', + 'Sebuah pemesanan pada hari itu: sebuah reservasi pada perhentian menandai perhentian itu “Reservasi dikonfirmasi” atau “Reservasi tertunda”, dan transportasi tampil sebagai “Keberangkatan” atau “Kedatangan” dengan waktu dan rutenya, dengan sakelar kecil yang menggambar rute itu di peta.', 'help.guide.read-day-plan.step.4': 'Di antara dua perhentian, penghubungnya menyebutkan berapa lama ruas itu dan berapa jauh, dalam moda perjalanan hari itu; klik untuk mengubah moda ruas yang satu itu.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/it/help.ts b/shared/src/i18n/it/help.ts index 424c73a13..4c3c23256 100644 --- a/shared/src/i18n/it/help.ts +++ b/shared/src/i18n/it/help.ts @@ -994,7 +994,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Le tue impostazioni personali, una scheda per argomento nella barra laterale a sinistra. La maggior parte degli interruttori si applica nel momento in cui li sposti; un modulo con un pulsante Salva in fondo lo aspetta. Niente qui cambia il TREK di qualcun altro.', 'help.ctx.settings.bullet.1': - 'Barra laterale a sinistra: Visualizzazione, Appearance, Mappa, Notifiche, Integrazioni, Offline e Account. Plugin compare appena ne è installato uno, Informazioni su un TREK self-hosted.', + 'Barra laterale a sinistra: Visualizzazione, Appearance, Mappa, Notifiche, Integrazioni, Offline e Account. Plugin compare appena ne è installato uno, Informazioni ovunque l’admin non l’abbia tolto.', 'help.ctx.settings.bullet.2': 'Visualizzazione è lingua, unità, valuta e con cosa si apre l’app; Appearance è tema, colori, dimensione del testo e i widget della dashboard.', 'help.ctx.settings.bullet.3': @@ -1954,7 +1954,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Una tappa: la maniglia a sinistra la trascina, l’immagine porta un lucchetto per l’ottimizzazione del percorso, poi il nome, la descrizione e, se ci sono, le Note per questo giorno. Un contrassegno di orario mostra Inizio e Fine quando la tappa li ha; le frecce che compaiono alla sua estremità destra la spostano su o giù.', 'help.guide.read-day-plan.step.3': - 'Una prenotazione nel giorno: un trasporto compare come Partenza o Arrivo con il suo orario e il suo tragitto, una prenotazione su una tappa la segna Prenotazione confermata o Prenotazione in attesa. Il piccolo interruttore su un trasporto mostra il suo percorso sulla mappa.', + 'Una prenotazione nel giorno: una prenotazione su una tappa la segna Prenotazione confermata o Prenotazione in attesa, e un trasporto compare come Partenza o Arrivo con il suo orario e il suo tragitto, con un piccolo interruttore che disegna quel percorso sulla mappa.', 'help.guide.read-day-plan.step.4': 'Tra due tappe il connettore dice quanto dura la tratta e quanto è lunga, nel mezzo di trasporto del giorno; cliccalo per cambiare il mezzo di quella sola tratta.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/ja/help.ts b/shared/src/i18n/ja/help.ts index 67297b1fe..df88ba90a 100644 --- a/shared/src/i18n/ja/help.ts +++ b/shared/src/i18n/ja/help.ts @@ -934,7 +934,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'あなた専用の設定で、左のサイドバーにトピックごとのタブが並びます。ほとんどのスイッチは切り替えた瞬間に反映されます。下部に「保存」ボタンのあるフォームは、そのボタンを押すまで待ちます。ここで変えたものが、ほかの人のTREKを変えることはありません。', 'help.ctx.settings.bullet.1': - '左のサイドバー:「表示」「Appearance」「地図」「通知」「連携」「オフライン」「アカウント」。「プラグイン」はプラグインが1つでもインストールされると現れ、「情報」はセルフホストのTREKで現れます。', + '左のサイドバー:「表示」「Appearance」「地図」「通知」「連携」「オフライン」「アカウント」。「プラグイン」はプラグインが1つでもインストールされると現れ、「情報」は運営者が外していないかぎり現れます。', 'help.ctx.settings.bullet.2': '「表示」は言語、単位、通貨、アプリが最初に開く画面。「Appearance」はテーマ、色、文字サイズ、ダッシュボードのウィジェットです。', 'help.ctx.settings.bullet.3': @@ -1872,7 +1872,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': '立ち寄り先。左のつまみでドラッグでき、写真にはルート最適化用の鍵があり、続いて名前、説明、設定してあれば「この日のメモ」が出ます。時刻がある立ち寄り先には「開始」と「終了」を示す時刻バッジが付き、右端に出る矢印で上下に動かせます。', 'help.guide.read-day-plan.step.3': - 'その日の予約。移動手段は「出発」または「到着」として時刻と経路つきで出て、立ち寄り先に紐づいた予約はその立ち寄り先を「予約確定」または「予約保留」と示します。移動手段の小さなトグルはその経路を地図に表示します。', + 'その日の予約。立ち寄り先に紐づいた予約はその立ち寄り先を「予約確定」または「予約保留」と示し、移動手段は「出発」または「到着」として時刻と経路つきで出て、小さなトグルがその経路を地図に描きます。', 'help.guide.read-day-plan.step.4': '2つの立ち寄り先の間のコネクターは、その日の移動手段でその区間の所要時間と距離を示します。クリックすると、その1区間だけ手段を変えられます。', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/ko/help.ts b/shared/src/i18n/ko/help.ts index 74cf1a928..1eac3926f 100644 --- a/shared/src/i18n/ko/help.ts +++ b/shared/src/i18n/ko/help.ts @@ -915,7 +915,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': '나의 개인 설정으로, 왼쪽 사이드바에 주제별 탭이 하나씩 있습니다. 대부분의 스위치는 바꾸는 순간 적용됩니다. 아래에 “저장” 버튼이 있는 양식은 그 버튼을 누를 때까지 기다립니다. 여기서 바꾼 것이 다른 사람의 TREK을 바꾸는 일은 없습니다.', 'help.ctx.settings.bullet.1': - '왼쪽 사이드바: “화면”, “Appearance”, “지도”, “알림”, “통합”, “오프라인”, “계정”. “플러그인”은 플러그인이 하나라도 설치되면 나타나고, “정보”는 직접 호스팅한 TREK에서 나타납니다.', + '왼쪽 사이드바: “화면”, “Appearance”, “지도”, “알림”, “통합”, “오프라인”, “계정”. “플러그인”은 플러그인이 하나라도 설치되면 나타나고, “정보”는 운영자가 없애지 않은 곳이라면 어디서나 나타납니다.', 'help.ctx.settings.bullet.2': '“화면”은 언어, 단위, 통화, 앱이 처음 여는 화면이고, “Appearance”는 테마, 색상, 글자 크기, 대시보드 위젯입니다.', 'help.ctx.settings.bullet.3': @@ -1793,7 +1793,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': '방문지: 왼쪽의 손잡이로 끌고, 사진에는 경로 최적화를 위한 잠금이 있으며, 이어서 이름, 설명, 그리고 적어 두었다면 “이 날의 메모”가 나옵니다. 시간이 있는 방문지에는 “시작”과 “종료”를 보여 주는 시간 배지가 붙고, 오른쪽 끝에 나타나는 화살표가 그것을 위나 아래로 옮깁니다.', 'help.guide.read-day-plan.step.3': - '그날의 예약: 교통편은 “출발” 또는 “도착”으로 시간과 경로와 함께 나오고, 방문지에 묶인 예약은 그 방문지를 “예약 확정” 또는 “예약 대기 중”으로 표시합니다. 교통편의 작은 토글은 그 경로를 지도에 보여 줍니다.', + '그날의 예약: 방문지에 묶인 예약은 그 방문지를 “예약 확정” 또는 “예약 대기 중”으로 표시하고, 교통편은 “출발” 또는 “도착”으로 시간과 경로와 함께 나오며, 작은 토글이 그 경로를 지도에 그려 줍니다.', 'help.guide.read-day-plan.step.4': '두 방문지 사이의 연결선은 그날의 이동 수단으로 그 구간이 얼마나 걸리고 얼마나 먼지 알려 줍니다. 클릭하면 그 한 구간의 수단을 바꿀 수 있습니다.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/nl/help.ts b/shared/src/i18n/nl/help.ts index 24953afe3..9da04b483 100644 --- a/shared/src/i18n/nl/help.ts +++ b/shared/src/i18n/nl/help.ts @@ -999,7 +999,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Je persoonlijke instellingen, één tabblad per onderwerp in de zijbalk links. De meeste schakelaars gelden zodra je ze omzet; een formulier met een knop Opslaan onderaan wacht daarop. Niets hier verandert de TREK van iemand anders.', 'help.ctx.settings.bullet.1': - 'Zijbalk links: Weergave, Appearance, Kaart, Meldingen, Integraties, Offline en Account. Plug-ins verschijnt zodra er een is geïnstalleerd, Over op een zelf gehoste TREK.', + 'Zijbalk links: Weergave, Appearance, Kaart, Meldingen, Integraties, Offline en Account. Plug-ins verschijnt zodra er een is geïnstalleerd, Over daar waar de beheerder het niet heeft weggehaald.', 'help.ctx.settings.bullet.2': 'Weergave is taal, eenheden, valuta en waarmee de app opent; Appearance is thema, kleuren, tekstgrootte en de dashboardwidgets.', 'help.ctx.settings.bullet.3': @@ -1972,7 +1972,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Een stop: het greepje links sleept hem, de afbeelding draagt een slotje voor de route-optimalisatie, dan de naam, de beschrijving en, als ze er zijn, de Notities voor deze dag. Een tijdlabel toont Starttijd en Einde als de stop die heeft; de pijlen die aan het rechteruiteinde verschijnen zetten hem omhoog of omlaag.', 'help.guide.read-day-plan.step.3': - 'Een boeking op de dag: vervoer verschijnt als Vertrek of Aankomst met tijd en traject, een reservering bij een stop markeert die stop als Reservering bevestigd of Reservering in behandeling. Het kleine schakelaartje op een vervoersrij toont de route op de kaart.', + 'Een boeking op de dag: een reservering bij een stop markeert die stop als Reservering bevestigd of Reservering in behandeling, en vervoer verschijnt als Vertrek of Aankomst met tijd en traject, met een klein schakelaartje dat die route op de kaart tekent.', 'help.guide.read-day-plan.step.4': 'Tussen twee stops zegt de verbinding hoe lang het traject duurt en hoe ver het is, in de vervoerswijze van de dag; klik erop om de wijze voor dat ene traject te veranderen.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/pl/help.ts b/shared/src/i18n/pl/help.ts index c50dff7c6..5d982896f 100644 --- a/shared/src/i18n/pl/help.ts +++ b/shared/src/i18n/pl/help.ts @@ -972,7 +972,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Twoje osobiste ustawienia, w pasku bocznym po lewej jedna zakładka na temat. Większość przełączników działa od razu po przestawieniu; formularz z przyciskiem Zapisz na dole czeka na niego. Nic tutaj nie zmienia TREK-a nikomu innemu.', 'help.ctx.settings.bullet.1': - 'Pasek boczny po lewej: Wygląd, Appearance, Mapa, Powiadomienia, Integracje, Offline i Konto. Wtyczki pojawiają się, gdy jakaś jest zainstalowana, O aplikacji na TREK-u hostowanym samodzielnie.', + 'Pasek boczny po lewej: Wygląd, Appearance, Mapa, Powiadomienia, Integracje, Offline i Konto. Wtyczki pojawiają się, gdy jakaś jest zainstalowana, O aplikacji wszędzie tam, gdzie administrator jej nie usunął.', 'help.ctx.settings.bullet.2': 'Wygląd to język, jednostki, waluta i to, z czym aplikacja się otwiera; Appearance to motyw, kolory, rozmiar tekstu i widżety pulpitu.', 'help.ctx.settings.bullet.3': @@ -1918,7 +1918,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Przystanek: uchwyt po lewej go przeciąga, zdjęcie nosi kłódkę dla optymalizacji trasy, dalej idą nazwa, opis i, jeśli są ustawione, Notatki na ten dzień. Plakietka godziny pokazuje Początek i Koniec, gdy przystanek je ma; strzałki, które pojawiają się na jego prawym końcu, przesuwają go w górę lub w dół.', 'help.guide.read-day-plan.step.3': - 'Rezerwacja w dniu: transport pokazuje się jako Wylot albo Przylot ze swoją godziną i trasą, rezerwacja przy przystanku oznacza przystanek jako Rezerwacja potwierdzona albo Rezerwacja oczekująca. Mały przełącznik na transporcie pokazuje jego trasę na mapie.', + 'Rezerwacja w dniu: rezerwacja przy przystanku oznacza przystanek jako Rezerwacja potwierdzona albo Rezerwacja oczekująca, a transport pokazuje się jako Wylot albo Przylot ze swoją godziną i trasą, z małym przełącznikiem, który rysuje tę trasę na mapie.', 'help.guide.read-day-plan.step.4': 'Między dwoma przystankami łącznik mówi, ile odcinek trwa i jak jest długi, w środku transportu dnia; kliknij go, by zmienić środek dla tego jednego odcinka.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/ru/help.ts b/shared/src/i18n/ru/help.ts index c8b987218..dc886dd3a 100644 --- a/shared/src/i18n/ru/help.ts +++ b/shared/src/i18n/ru/help.ts @@ -972,7 +972,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Ваши личные настройки, по одной вкладке на тему в боковой панели слева. Большинство переключателей срабатывают сразу, как вы их переключите; форма с кнопкой Сохранить внизу ждёт нажатия. Ничто здесь не меняет TREK для кого-то другого.', 'help.ctx.settings.bullet.1': - 'Боковая панель слева: Основные, Внешний вид, Карта, Уведомления, Интеграции, Офлайн-режим и Аккаунт. Плагины появляются, когда хотя бы один установлен, О приложении на TREK, который вы разместили сами.', + 'Боковая панель слева: Основные, Внешний вид, Карта, Уведомления, Интеграции, Офлайн-режим и Аккаунт. Плагины появляются, когда хотя бы один установлен, О приложении везде, где администратор эту вкладку не убрал.', 'help.ctx.settings.bullet.2': 'Основные: язык, единицы, валюта и то, с чего приложение открывается; Внешний вид: тема, цвета, размер текста и элементы главной страницы.', 'help.ctx.settings.bullet.3': @@ -1932,7 +1932,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Остановка: рукоятка слева её тащит, картинка несёт замок для оптимизации маршрута, дальше идут название, описание и, если заданы, Заметки на этот день. Метка времени показывает Начало и Конец, когда они у остановки есть; стрелки, которые появляются у её правого края, двигают её вверх или вниз.', 'help.guide.read-day-plan.step.3': - 'Бронирование на дне: транспорт показывается как Вылет или Прилёт со своим временем и маршрутом, бронирование у остановки помечает остановку как Бронирование подтверждено или Бронирование ожидает. Маленький переключатель на транспорте показывает его маршрут на карте.', + 'Бронирование на дне: бронирование у остановки помечает остановку как Бронирование подтверждено или Бронирование ожидает, а транспорт показывается как Вылет или Прилёт со своим временем и маршрутом, с маленьким переключателем, который рисует этот маршрут на карте.', 'help.guide.read-day-plan.step.4': 'Между двумя остановками связка говорит, сколько участок занимает и какой он длины, в способе передвижения дня; нажмите на неё, чтобы сменить способ для этого одного участка.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/sv/help.ts b/shared/src/i18n/sv/help.ts index c616fe298..c339c73f1 100644 --- a/shared/src/i18n/sv/help.ts +++ b/shared/src/i18n/sv/help.ts @@ -960,7 +960,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Dina personliga inställningar, en flik per ämne i sidopanelen till vänster. De flesta reglage slår igenom i samma stund du växlar dem; ett formulär med en Spara-knapp längst ner väntar på den. Inget här ändrar någon annans TREK.', 'help.ctx.settings.bullet.1': - 'Sidopanelen till vänster: Visning, Appearance, Karta, Meddelanden, Integrationer, Offline och Konto. Tillägg dyker upp så snart ett är installerat, Om på en TREK du kör själv.', + 'Sidopanelen till vänster: Visning, Appearance, Karta, Meddelanden, Integrationer, Offline och Konto. Tillägg dyker upp så snart ett är installerat, Om överallt där administratören inte har tagit bort fliken.', 'help.ctx.settings.bullet.2': 'Visning är språk, enheter, valuta och vad appen öppnar med; Appearance är tema, färger, textstorlek och översiktens widgetar.', 'help.ctx.settings.bullet.3': @@ -1893,7 +1893,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Ett stopp: greppet till vänster drar det, bilden bär ett lås för ruttoptimeringen, sedan kommer namnet, beskrivningen och, om de är satta, Anteckningar för denna dag. En tidsbricka visar Börjar och Slutar när stoppet har dem; pilarna som dyker upp i dess högra kant flyttar det upp eller ner.', 'help.guide.read-day-plan.step.3': - 'En bokning på dagen: en transport visas som Avgång eller Ankomst med sin tid och sin väg, en bokning vid ett stopp märker stoppet Bokningen bekräftad eller Bokningen väntar på bekräftelse. Den lilla knappen på en transport visar dess väg på kartan.', + 'En bokning på dagen: en bokning vid ett stopp märker stoppet Bokningen bekräftad eller Bokningen väntar på bekräftelse, och en transport visas som Avgång eller Ankomst med sin tid och sin väg, med en liten knapp som ritar den vägen på kartan.', 'help.guide.read-day-plan.step.4': 'Mellan två stopp säger länken hur lång tid etappen tar och hur långt det är, i dagens färdsätt; klicka på den för att byta färdsätt för just den etappen.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/tr/help.ts b/shared/src/i18n/tr/help.ts index eb43dbff1..8ef8cc152 100644 --- a/shared/src/i18n/tr/help.ts +++ b/shared/src/i18n/tr/help.ts @@ -978,7 +978,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Kişisel ayarlarınız, soldaki kenar çubuğunda her konu için bir sekme. Anahtarların çoğu çevirdiğiniz anda uygulanır; altında Kaydet düğmesi olan bir form onu bekler. Buradaki hiçbir şey başkasının TREK’ini değiştirmez.', 'help.ctx.settings.bullet.1': - 'Soldaki kenar çubuğu: Görünüm, Appearance, Harita, Bildirimler, Entegrasyonlar, Çevrimdışı ve Hesap. Eklentiler bir tane kurulduğunda, Hakkında ise kendi barındırdığınız bir TREK’te görünür.', + 'Soldaki kenar çubuğu: Görünüm, Appearance, Harita, Bildirimler, Entegrasyonlar, Çevrimdışı ve Hesap. Eklentiler bir tane kurulduğunda, Hakkında ise yöneticinin kaldırmadığı her yerde görünür.', 'help.ctx.settings.bullet.2': 'Görünüm dil, birimler, para birimi ve uygulamanın neyle açılacağıdır; Appearance tema, renkler, metin boyutu ve pano bileşenleridir.', 'help.ctx.settings.bullet.3': @@ -1929,7 +1929,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Bir durak: soldaki tutamak onu sürükler, resim rota optimizasyonu için bir kilit taşır, sonra ad, açıklama ve ayarlıysa Bu gün için notlar gelir. Durağın saatleri varsa bir saat rozeti Başlangıç ve Son değerlerini gösterir; sağ ucunda beliren oklar onu yukarı ya da aşağı taşır.', 'help.guide.read-day-plan.step.3': - 'Gündeki bir rezervasyon: bir ulaşım, saati ve rotasıyla Kalkış ya da Varış olarak görünür, bir durağa bağlı rezervasyon o durağı Rezervasyon onaylandı ya da Rezervasyon beklemede olarak işaretler. Ulaşımın üzerindeki küçük düğme onun rotasını haritada gösterir.', + 'Gündeki bir rezervasyon: bir durağa bağlı rezervasyon o durağı Rezervasyon onaylandı ya da Rezervasyon beklemede olarak işaretler, bir ulaşım ise saati ve rotasıyla Kalkış ya da Varış olarak görünür; üzerindeki küçük düğme o rotayı haritada çizer.', 'help.guide.read-day-plan.step.4': 'İki durak arasında bağlayıcı, etabın günün ulaşım türüyle ne kadar sürdüğünü ve ne kadar uzun olduğunu söyler; yalnızca o etabın türünü değiştirmek için üzerine tıklayın.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/uk/help.ts b/shared/src/i18n/uk/help.ts index f3775347b..39f40f68c 100644 --- a/shared/src/i18n/uk/help.ts +++ b/shared/src/i18n/uk/help.ts @@ -962,7 +962,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Ваші особисті налаштування, по одній вкладці на тему в бічній панелі ліворуч. Більшість перемикачів спрацьовують одразу, щойно ви їх перемкнете; форма з кнопкою Зберегти внизу чекає на натискання. Ніщо тут не змінює TREK для когось іншого.', 'help.ctx.settings.bullet.1': - 'Бічна панель ліворуч: Відображення, Appearance, Карта, Сповіщення, Інтеграції, Офлайн і Обліковий запис. Плагіни з’являються, коли хоч один встановлено, Про застосунок на TREK, який ви розмістили самі.', + 'Бічна панель ліворуч: Відображення, Appearance, Карта, Сповіщення, Інтеграції, Офлайн і Обліковий запис. Плагіни з’являються, коли хоч один встановлено, Про застосунок скрізь, де адміністратор цю вкладку не прибрав.', 'help.ctx.settings.bullet.2': 'Відображення: мова, одиниці, валюта й те, з чого відкривається застосунок; Appearance: тема, кольори, розмір тексту й віджети головної сторінки.', 'help.ctx.settings.bullet.3': @@ -1905,7 +1905,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Зупинка: держак ліворуч її тягне, картинка несе замок для оптимізації маршруту, далі йдуть назва, опис і, якщо задані, Нотатки на цей день. Позначка часу показує Початок і Кінець, коли вони в зупинки є; стрілки, що з’являються біля її правого краю, рухають її вгору або вниз.', 'help.guide.read-day-plan.step.3': - 'Бронювання на дні: транспорт показується як Виліт або Приліт зі своїм часом і маршрутом, бронювання біля зупинки позначає зупинку як Бронювання підтверджено або Бронювання очікує. Маленький перемикач на транспорті показує його маршрут на карті.', + 'Бронювання на дні: бронювання біля зупинки позначає зупинку як Бронювання підтверджено або Бронювання очікує, а транспорт показується як Виліт або Приліт зі своїм часом і маршрутом, з маленьким перемикачем, який малює цей маршрут на карті.', 'help.guide.read-day-plan.step.4': 'Між двома зупинками з’єднувач каже, скільки ділянка триває і яка вона завдовжки, у способі пересування дня; натисніть на нього, щоб змінити спосіб для тієї однієї ділянки.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/vi/help.ts b/shared/src/i18n/vi/help.ts index 5233042f2..25136c6c6 100644 --- a/shared/src/i18n/vi/help.ts +++ b/shared/src/i18n/vi/help.ts @@ -967,7 +967,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': 'Cài đặt cá nhân của bạn, mỗi chủ đề một tab trong thanh bên bên trái. Hầu hết công tắc có hiệu lực ngay khi bạn gạt; biểu mẫu có nút “Lưu” ở dưới cùng sẽ chờ bạn nhấn nút đó. Không có gì ở đây làm thay đổi TREK của người khác.', 'help.ctx.settings.bullet.1': - 'Thanh bên bên trái: “Hiển thị”, “Giao diện”, “Bản đồ”, “Thông báo”, “Tích hợp”, “Ngoại tuyến” và “Tài khoản”. “Plugin” xuất hiện khi đã cài một plugin, “Về” xuất hiện trên TREK tự lưu trữ.', + 'Thanh bên bên trái: “Hiển thị”, “Giao diện”, “Bản đồ”, “Thông báo”, “Tích hợp”, “Ngoại tuyến” và “Tài khoản”. “Plugin” xuất hiện khi đã cài một plugin, “Về” xuất hiện ở bất cứ nơi nào người vận hành chưa gỡ nó đi.', 'help.ctx.settings.bullet.2': '“Hiển thị” là ngôn ngữ, đơn vị, tiền tệ và màn hình ứng dụng mở lúc đầu; “Giao diện” là chủ đề, màu sắc, cỡ chữ và các tiện ích của bảng điều khiển.', 'help.ctx.settings.bullet.3': @@ -1911,7 +1911,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': 'Một điểm dừng: tay nắm ở bên trái để kéo nó, hình ảnh mang một ổ khóa dành cho việc tối ưu hóa lộ trình, rồi đến tên, mô tả và, nếu có, “Ghi chú cho ngày này”. Một huy hiệu thời gian hiển thị “Bắt đầu” và “Kết thúc” khi điểm dừng có chúng; các mũi tên hiện ra ở đầu bên phải sẽ đưa nó lên hoặc xuống.', 'help.guide.read-day-plan.step.3': - 'Một đặt chỗ trong ngày: phương tiện di chuyển hiện ra là “Khởi hành” hoặc “Đến” cùng giờ và lộ trình của nó, còn một đặt chỗ gắn với điểm dừng sẽ đánh dấu điểm dừng đó là “Đã xác nhận đặt chỗ” hoặc “Đang chờ đặt chỗ”. Công tắc nhỏ trên một phương tiện di chuyển hiển thị lộ trình của nó trên bản đồ.', + 'Một đặt chỗ trong ngày: một đặt chỗ gắn với điểm dừng sẽ đánh dấu điểm dừng đó là “Đã xác nhận đặt chỗ” hoặc “Đang chờ đặt chỗ”, còn phương tiện di chuyển hiện ra là “Khởi hành” hoặc “Đến” cùng giờ và lộ trình của nó, với một công tắc nhỏ vẽ lộ trình đó lên bản đồ.', 'help.guide.read-day-plan.step.4': 'Giữa hai điểm dừng, đường nối cho biết chặng đó mất bao lâu và xa bao nhiêu, theo phương thức di chuyển của ngày; nhấp vào đó để đổi phương thức cho riêng chặng ấy.', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/zh-TW/help.ts b/shared/src/i18n/zh-TW/help.ts index 30d0ea2db..9f0946a43 100644 --- a/shared/src/i18n/zh-TW/help.ts +++ b/shared/src/i18n/zh-TW/help.ts @@ -815,7 +815,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': '你的個人設定,左側邊欄裡每個主題一個分頁。大多數開關一撥就生效;底部帶「儲存」按鈕的表單要等你按下儲存。這裡的任何改動都不會影響別人的 TREK。', 'help.ctx.settings.bullet.1': - '左側邊欄:「顯示」「Appearance」「地圖」「通知」「整合」「Offline」和「帳戶」。裝了外掛後會出現「外掛」,自架的 TREK 上會出現「關於」。', + '左側邊欄:「顯示」「Appearance」「地圖」「通知」「整合」「Offline」和「帳戶」。裝了外掛後會出現「外掛」,營運者沒有把它拿掉的地方就會出現「關於」。', 'help.ctx.settings.bullet.2': '「顯示」管語言、單位、貨幣和應用程式開啟時的頁面;「Appearance」管主題、顏色、文字大小和儀表板小工具。', 'help.ctx.settings.bullet.3': @@ -1589,7 +1589,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': '一個停留點:左邊的握把用來拖曳,圖片上帶著用於路線最佳化的鎖,接著是名稱、描述,以及設定過的話,「當天備註」。停留點有時間時會顯示一個標出「開始」和「結束」的時間標籤;它右端出現的箭頭把它上移或下移。', 'help.guide.read-day-plan.step.3': - '當天的預訂:交通顯示為「出發」或「到達」,帶時間和路線;綁在某個停留點上的預訂會把這個停留點標為「預訂已確認」或「預訂待確認」。交通上的小開關把它的路線顯示在地圖上。', + '當天的預訂:綁在某個停留點上的預訂會把這個停留點標為「預訂已確認」或「預訂待確認」;交通顯示為「出發」或「到達」,帶時間和路線,旁邊一個小開關把那條路線畫在地圖上。', 'help.guide.read-day-plan.step.4': '兩個停留點之間的連接線按當天的交通方式說明這一段要多久、有多遠;點選它就能只改這一段的方式。', 'help.guide.read-day-plan.step.5': diff --git a/shared/src/i18n/zh/help.ts b/shared/src/i18n/zh/help.ts index 2a16370ec..2fcf00e54 100644 --- a/shared/src/i18n/zh/help.ts +++ b/shared/src/i18n/zh/help.ts @@ -801,7 +801,7 @@ const help: TranslationStrings = { 'help.ctx.settings.summary': '你的个人设置,左侧边栏里每个主题一个标签页。大多数开关一拨就生效;底部带“保存”按钮的表单要等你点保存。这里的任何改动都不会影响别人的 TREK。', 'help.ctx.settings.bullet.1': - '左侧边栏:“显示”“外观”“地图”“通知”“集成”“离线”和“账户”。装了插件后会出现“插件”,自托管的 TREK 上会出现“关于”。', + '左侧边栏:“显示”“外观”“地图”“通知”“集成”“离线”和“账户”。装了插件后会出现“插件”,运营者没有把它拿掉的地方就会出现“关于”。', 'help.ctx.settings.bullet.2': '“显示”管语言、单位、货币和应用打开时的页面;“外观”管主题、颜色、文本大小和仪表盘组件。', 'help.ctx.settings.bullet.3': @@ -1554,7 +1554,7 @@ const help: TranslationStrings = { 'help.guide.read-day-plan.step.2': '一个停留点:左边的握柄用来拖动,图片上带着用于路线优化的锁,接着是名称、描述,以及设置过的话,“当天备注”。停留点有时间时会显示一个标出“开始”和“结束”的时间标签;它右端出现的箭头把它上移或下移。', 'help.guide.read-day-plan.step.3': - '当天的预订:交通显示为“出发”或“到达”,带时间和路线;绑在某个停留点上的预订会把这个停留点标为“预订已确认”或“预订待确认”。交通上的小开关把它的路线显示在地图上。', + '当天的预订:绑在某个停留点上的预订会把这个停留点标为“预订已确认”或“预订待确认”;交通显示为“出发”或“到达”,带时间和路线,旁边一个小开关把那条路线画在地图上。', 'help.guide.read-day-plan.step.4': '两个停留点之间的连接线按当天的出行方式说明这一段要多久、有多远;点击它就能只改这一段的方式。', 'help.guide.read-day-plan.step.5':