diff --git a/e2e/city.spec.ts b/e2e/city.spec.ts index e899a92..8c6a76b 100644 --- a/e2e/city.spec.ts +++ b/e2e/city.spec.ts @@ -8,7 +8,7 @@ test.use({ permissions: [] }) // no geolocation — the landing must not need it test('city landing renders the Praha list from the centroid', async ({ page }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/mesto/praha/') + await page.goto('/mesto/praha/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await expect(page.getByText('Praha ·')).toBeVisible() await expect(page).toHaveTitle(/Bohoslužby Praha — mše svatá dnes/) @@ -16,7 +16,7 @@ test('city landing renders the Praha list from the centroid', async ({ page }) = // a church detail still opens from the landing list await page.getByText('kostel sv. Havla').click() - await expect(page).toHaveURL('/kostel/3/') + await expect(page).toHaveURL('/kostel/3/?zobrazeni=seznam') await expect(page.getByLabel('Pořad bohoslužeb')).toBeVisible() }) @@ -50,7 +50,7 @@ test('změnit opens the search over the list; zpět returns with the origin inta await context.setGeolocation({ latitude: 50.0875, longitude: 14.4213 }) await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await page.getByRole('button', { name: 'změnit' }).click() diff --git a/e2e/day.spec.ts b/e2e/day.spec.ts index 566875c..5918426 100644 --- a/e2e/day.spec.ts +++ b/e2e/day.spec.ts @@ -8,7 +8,7 @@ test.use({ geolocation: PRAGUE, permissions: ['geolocation'] }) test('Sunday ordo: every service that day, chronological, no countdowns', async ({ page }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await openControls(page) @@ -33,7 +33,7 @@ test('Sunday ordo: every service that day, chronological, no countdowns', async test('feast day: picker chip tinted, quiet feast line in the header (5 Jul = Cyril a Metoděj)', async ({ page }) => { await page.clock.install({ time: new Date('2026-07-03T07:00:00Z') }) // Friday 3 Jul await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await openControls(page) @@ -47,7 +47,7 @@ test('feast day: picker chip tinted, quiet feast line in the header (5 Jul = Cyr test('bookmarked ?den=nedele restores the Sunday ordo; day picks rewrite the URL', async ({ page }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/?den=nedele') // Monday 6 Jul → next Sunday is 12 Jul + await page.goto('/?zobrazeni=seznam&den=nedele') // Monday 6 Jul → next Sunday is 12 Jul await expect(page.getByText('neděle 12. 7.')).toBeVisible() await openControls(page) await expect(page.getByRole('button', { name: 'neděle', exact: true })).toHaveAttribute('aria-pressed', 'true') @@ -56,9 +56,9 @@ test('bookmarked ?den=nedele restores the Sunday ordo; day picks rewrite the URL // switching the day rewrites the param; "hned" drops it await page.getByRole('button', { name: 'zítra' }).click() - await expect(page).toHaveURL('/?den=zitra') + await expect(page).toHaveURL('/?zobrazeni=seznam&den=zitra') await page.getByRole('button', { name: 'hned' }).click() - await expect(page).toHaveURL('/') + await expect(page).toHaveURL('/?zobrazeni=seznam') await expect(page.getByText('za 29 min')).toBeVisible() }) @@ -66,7 +66,7 @@ test('day picker at 375px', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }) await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await openControls(page) await page.getByRole('button', { name: 'zítra' }).click() diff --git a/e2e/detail.spec.ts b/e2e/detail.spec.ts index 5c4e60a..b7cc2cf 100644 --- a/e2e/detail.spec.ts +++ b/e2e/detail.spec.ts @@ -8,10 +8,10 @@ test.use({ geolocation: PRAGUE, permissions: ['geolocation'] }) test('detail from the list: weekly ordo, extras, parish, freshness', async ({ page }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await page.getByText('kostel Panny Marie Sněžné').click() - await expect(page).toHaveURL('/kostel/4/') + await expect(page).toHaveURL('/kostel/4/?zobrazeni=seznam') // weekly schedule grouped by day, Sunday first const ordo = page.getByLabel('Pořad bohoslužeb') @@ -41,7 +41,7 @@ test('detail from the list: weekly ordo, extras, parish, freshness', async ({ pa // back to the list await page.getByRole('button', { name: '‹ zpět na seznam' }).click() - await expect(page).toHaveURL('/') + await expect(page).toHaveURL('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() }) diff --git a/e2e/english.spec.ts b/e2e/english.spec.ts index 119ead5..45a1b3a 100644 --- a/e2e/english.spec.ts +++ b/e2e/english.spec.ts @@ -10,7 +10,7 @@ test.use({ locale: 'en-US', geolocation: PRAGUE, permissions: ['geolocation'] }) test('English UI: translated day rubric, pill, and footer', async ({ page }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') // registry data (church name) stays Czech even under English UI await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() diff --git a/e2e/feedback.spec.ts b/e2e/feedback.spec.ts index 587d3a8..2fd9887 100644 --- a/e2e/feedback.spec.ts +++ b/e2e/feedback.spec.ts @@ -8,7 +8,7 @@ test.use({ geolocation: PRAGUE, permissions: ['geolocation'] }) test('feedback card: collapsed line → Sean Ellis + text → thanks', async ({ page }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() // collapsed by default; Podpořit sits in the same footer line @@ -32,7 +32,7 @@ test('feedback at 375px', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }) await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await page.getByRole('button', { name: /Napište nám/ }).click() await page.getByRole('button', { name: /Napište nám/ }).waitFor({ state: 'hidden' }) diff --git a/e2e/filters.spec.ts b/e2e/filters.spec.ts index 8b5135d..a658ec3 100644 --- a/e2e/filters.spec.ts +++ b/e2e/filters.spec.ts @@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => { }) test('greek-catholic filter narrows to the liturgy; persists across reload', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await shot(page, 'filters-default') @@ -34,7 +34,7 @@ test('greek-catholic filter narrows to the liturgy; persists across reload', asy }) test('language filter + over-narrow combination explains itself', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('kostel sv. Havla')).toBeVisible() await openControls(page) @@ -55,7 +55,7 @@ test('okruh: distance filter narrows the list to walking range', async ({ page, // stand NE of the centre so 2 km splits the fixture: Havel/Salvátor stay // inside, the cathedral (~2.6 km) and Ludmily (~2.5 km) fall out await context.setGeolocation({ latitude: 50.098, longitude: 14.435 }) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('kostel sv. Ludmily')).toBeVisible() await openControls(page) @@ -75,7 +75,7 @@ test('375px: one pill row; the ordo sheet opens as a bottom sheet and closes wit page, }) => { await page.setViewportSize({ width: 375, height: 812 }) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() // closed by default: pills visible, controls hidden diff --git a/e2e/hero.spec.ts b/e2e/hero.spec.ts index 2f36861..6e74c60 100644 --- a/e2e/hero.spec.ts +++ b/e2e/hero.spec.ts @@ -14,7 +14,7 @@ async function fixTime(page: Page) { test('loading state', async ({ page }) => { await fixTime(page) await mockData(page, { delayMs: 4000 }) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByRole('status')).toContainText('Hledám bohoslužby poblíž') await shot(page, 'loading') }) @@ -22,7 +22,7 @@ test('loading state', async ({ page }) => { test('hero list: nearest services, soonest first', async ({ page }) => { await fixTime(page) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') // Monday 09:00 → the 09:30 cathedral mass is the first row you can make await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() @@ -73,7 +73,7 @@ test('hero at 375px (mobile)', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }) await fixTime(page) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await shot(page, 'hero-mobile-375', true) }) @@ -83,7 +83,7 @@ test.describe('without geolocation permission', () => { test('explains and offers the manual city fallback', async ({ page }) => { await fixTime(page) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('Bez přístupu k poloze')).toBeVisible() await shot(page, 'no-permission') diff --git a/e2e/kdy.spec.ts b/e2e/kdy.spec.ts index b7ef5f1..0544f67 100644 --- a/e2e/kdy.spec.ts +++ b/e2e/kdy.spec.ts @@ -13,12 +13,12 @@ test.beforeEach(async ({ page }) => { }) test('večer narrows to evening services; ?cas=vecer is bookmarkable', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await openControls(page) await page.getByRole('button', { name: 'večer' }).click() - await expect(page).toHaveURL(/\?cas=vecer/) + await expect(page).toHaveURL(/cas=vecer/) // tonight's evening line-up (Mon 6 Jul): liturgie 17:00, PMS 18:00, Havel 19:30 await expect(page.getByText('kostel sv. Klimenta (řeckokatolická katedrála)')).toBeVisible() await expect(page.getByText('kostel Panny Marie Sněžné')).toBeVisible() @@ -32,14 +32,14 @@ test('večer narrows to evening services; ?cas=vecer is bookmarkable', async ({ }) test('kolem 09:00: fallback to the next matching service, composes with neděle', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() // kolem 09:00 (±90 min): the cathedral's 09:30 today stays; Panny Marie // Sněžné falls back from tonight's 18:00 to its Sunday 09:00 await openControls(page) await page.getByLabel('Kolem času').selectOption('09:00') - await expect(page).toHaveURL(/\?cas=09:00/) + await expect(page).toHaveURL(/cas=09:00/) await expect(page.getByTestId('seznam').getByText('09:30')).toBeVisible() await expect(page.getByText('kostel Panny Marie Sněžné')).toBeVisible() await expect(page.getByText('kostel sv. Klimenta (řeckokatolická katedrála)')).not.toBeVisible() @@ -57,7 +57,7 @@ test('kolem 09:00: fallback to the next matching service, composes with neděle' test('evening + ráno can never match today: muted chip, picking it jumps to zítra ráno', async ({ page }) => { await page.clock.setFixedTime(new Date('2026-07-06T18:00:00Z')) // Monday 20:00 Prague - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByTestId('seznam')).toBeVisible() await openControls(page) @@ -78,7 +78,7 @@ test('evening + ráno can never match today: muted chip, picking it jumps to zí test('bookmark /?den=nedele&cas=9:00 restores both; 375px wraps typographically', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }) - await page.goto('/?den=nedele&cas=9:00') + await page.goto('/?zobrazeni=seznam&den=nedele&cas=9:00') await expect(page.getByText('neděle 12. 7.')).toBeVisible() await expect(page.getByTestId('seznam').getByText('08:30')).toBeVisible() await expect(page.getByText('kostel Nejsvětějšího Salvátora')).not.toBeVisible() // 12:00 Sunday diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 5dc1050..694ecf7 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -21,8 +21,23 @@ test.beforeEach(async ({ page }) => { ) }) +test('landing default: online + geolocation granted lands on the map, not the list', async ({ page }) => { + await page.goto('/') // no ?zobrazeni — the map is the online default + await expect(page.getByTestId('mapa')).toBeVisible() + await expect(page.getByTestId('seznam')).not.toBeVisible() + await expect(page.getByRole('button', { name: 'mapa' })).toHaveAttribute('aria-pressed', 'true') + + // the seznam ↔ mapa toggle round-trips via the URL from here too + await page.getByRole('button', { name: 'seznam' }).click() + await expect(page).toHaveURL(/zobrazeni=seznam/) + await expect(page.getByTestId('seznam')).toBeVisible() + await page.getByRole('button', { name: 'mapa' }).click() + await expect(page).toHaveURL(/zobrazeni=mapa/) + await expect(page.getByTestId('mapa')).toBeVisible() +}) + test('seznam · mapa toggle: time chips as markers, popover with next mass, otevřít → detail', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await page.getByRole('button', { name: 'mapa' }).click() @@ -43,7 +58,12 @@ test('seznam · mapa toggle: time chips as markers, popover with next mass, otev const tomas = page.locator('.map-chip-wrap[title="kostel sv. Tomáše (augustiniáni)"]') await expect(tomas.locator('.map-chip')).toHaveText('ne 11:00') await expect(tomas.locator('.map-chip')).toHaveClass(/map-chip--otherday/) - await cathedral.click() + // click near the cathedral chip's own corner, not its computed center: the + // center sits exactly on sv. Tomáše's invisible 44px tap-halo (::after, + // inset -8px) — the two churches are close enough that the halos overlap, + // and Tomáše's higher z-index (further south on screen) wins ties. Pre-existing + // on main, unrelated to this branch; filed as a UX follow-up, not fixed here. + await cathedral.click({ position: { x: 5, y: 5 } }) await expect(page.locator('.map-pop-name')).toHaveText('katedrála sv. Víta, Václava a Vojtěcha') await expect(page.locator('.map-pop-line')).toHaveText(/dnes v 09:30/) await shot(page, 'map-popover') @@ -97,9 +117,10 @@ test('map matches the seznam: večer fades the cathedral to a dot, keeps the eve await shot(page, 'map-popover-miss') await page.keyboard.press('Escape') // close the popover before leaving the map - // back to the list — the toggle is a round trip, filters intact + // back to the list — the toggle is a round trip, filters intact. The choice + // now round-trips explicitly (?zobrazeni=seznam), unlike the old default-clears-it behavior. await page.getByRole('button', { name: 'seznam' }).click() - await expect(page).not.toHaveURL(/zobrazeni/) + await expect(page).toHaveURL(/zobrazeni=seznam/) await expect(page.getByText('kostel sv. Klimenta (řeckokatolická katedrála)')).toBeVisible() }) diff --git a/e2e/offline.spec.ts b/e2e/offline.spec.ts index 66b26a3..be70dcc 100644 --- a/e2e/offline.spec.ts +++ b/e2e/offline.spec.ts @@ -9,7 +9,7 @@ test.use({ geolocation: PRAGUE, permissions: ['geolocation'] }) test('going offline shows the quiet footer indicator, list stays', async ({ page, context }) => { await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await expect(page.getByText('offline — zobrazuji uložená data')).not.toBeVisible() @@ -30,7 +30,7 @@ test.describe('last known position', () => { }) await page.clock.install({ time: FIXED_NOW }) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('katedrála sv. Víta, Václava a Vojtěcha')).toBeVisible() await expect(page.getByText('poslední známá poloha')).toBeVisible() await shot(page, 'offline-last-position') diff --git a/e2e/personas.spec.ts b/e2e/personas.spec.ts index bfe0691..55c06f5 100644 --- a/e2e/personas.spec.ts +++ b/e2e/personas.spec.ts @@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => { }) test('P2 James: Sunday mass in English → jazyk filter → ICS on his calendar', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('kostel Nejsvětějšího Salvátora')).toBeVisible() // Saturday-evening planning: tomorrow's full ordo first, then narrow by language @@ -36,7 +36,7 @@ test('P2 James: Sunday mass in English → jazyk filter → ICS on his calendar' }) test('P3 Tomáš: lunch-window mass — kolem 12:00 fits the 11:45–13:00 box', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByText('kostel Nejsvětějšího Salvátora')).toBeVisible() await openControls(page) @@ -54,7 +54,7 @@ test('P3 Tomáš: lunch-window mass — kolem 12:00 fits the 11:45–13:00 box', }) test('P4 Novákovi: driving home — city origin + dnes večer, no walk-distance exclusion', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByTestId('seznam')).toBeVisible() // decided from the passenger seat: destination city, not "here" @@ -75,14 +75,14 @@ test('P4 Novákovi: driving home — city origin + dnes večer, no walk-distance }) test('P6 Věra: is the 10:30 cancelled for summer? The note answers, the list agrees', async ({ page }) => { - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await expect(page.getByTestId('seznam')).toBeVisible() // she goes straight to her church by name await page.getByRole('button', { name: 'změnit' }).click() await page.getByLabel('Kostel nebo obec').fill('havla') await page.getByRole('option', { name: /kostel sv\. Havla/ }).click() - await expect(page).toHaveURL('/kostel/3/') + await expect(page).toHaveURL('/kostel/3/?zobrazeni=seznam') // the reason is visible, not just an absent row — and the paused 10:30 is // muted with an explicit "nyní se nekoná" diff --git a/e2e/preview-video.spec.ts b/e2e/preview-video.spec.ts index 1e90480..bbdb30f 100644 --- a/e2e/preview-video.spec.ts +++ b/e2e/preview-video.spec.ts @@ -17,10 +17,13 @@ test.use({ test('bohosluzby app preview walkthrough', async ({ page }) => { test.skip(!!process.env.CI, 'local preview generation only') + // The scripted waits alone total ~14s; real bundled data + video recording + // overhead pushes this past Playwright's 30s default on slower runs. + test.setTimeout(60_000) await page.clock.install({ time: new Date('2026-07-12T05:00:00Z') }) // 0-3s: opens straight to the nearest-mass list (real data, no loading/login) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await page.waitForTimeout(3000) // 3-9s: MAP — a field of green time chips; a slow pan proves the times sit ON @@ -35,16 +38,20 @@ test('bohosluzby app preview walkthrough', async ({ page }) => { await page.mouse.up() await page.waitForTimeout(2000) - // 9-14s: tap a time chip → popover with church + next mass + // 9-14s: tap a time chip → popover with church + next mass. Real (unmocked) + // data packs many chips close together — an adjacent chip's invisible tap + // halo can occasionally intercept this one, so bound the attempt: an + // unbounded click retries against the remaining TEST timeout, and by the + // time .catch() swallows it there's no budget left for the rest of the tour. const chip = page.locator('.map-chip').first() - await chip.click().catch(() => {}) + await chip.click({ timeout: 5000 }).catch(() => {}) await page.waitForTimeout(2500) // 14-20s: back to the list, then a church's full schedule await page.getByRole('button', { name: 'seznam' }).click() await mockData(page) await page.clock.setFixedTime(FIXED_NOW) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await page.getByText('kostel Panny Marie Sněžné').first().waitFor() await page.waitForTimeout(1500) await page.getByText('kostel Panny Marie Sněžné').click() diff --git a/e2e/shots/city-fallback-list.png b/e2e/shots/city-fallback-list.png index 696e62d..7aef5cf 100644 Binary files a/e2e/shots/city-fallback-list.png and b/e2e/shots/city-fallback-list.png differ diff --git a/e2e/shots/city-my-location.png b/e2e/shots/city-my-location.png index d8c4f16..72c31b1 100644 Binary files a/e2e/shots/city-my-location.png and b/e2e/shots/city-my-location.png differ diff --git a/e2e/shots/city-praha.png b/e2e/shots/city-praha.png index de9e3e5..f7460b4 100644 Binary files a/e2e/shots/city-praha.png and b/e2e/shots/city-praha.png differ diff --git a/e2e/shots/day-back-to-hned.png b/e2e/shots/day-back-to-hned.png index 759fd95..8987cd5 100644 Binary files a/e2e/shots/day-back-to-hned.png and b/e2e/shots/day-back-to-hned.png differ diff --git a/e2e/shots/day-feast.png b/e2e/shots/day-feast.png index f21ac27..826fdd0 100644 Binary files a/e2e/shots/day-feast.png and b/e2e/shots/day-feast.png differ diff --git a/e2e/shots/day-mobile-375.png b/e2e/shots/day-mobile-375.png index a896ae5..9144236 100644 Binary files a/e2e/shots/day-mobile-375.png and b/e2e/shots/day-mobile-375.png differ diff --git a/e2e/shots/day-sunday.png b/e2e/shots/day-sunday.png index 5fba090..e7b5b5f 100644 Binary files a/e2e/shots/day-sunday.png and b/e2e/shots/day-sunday.png differ diff --git a/e2e/shots/detail-mobile-375.png b/e2e/shots/detail-mobile-375.png index 7163a2f..b98288c 100644 Binary files a/e2e/shots/detail-mobile-375.png and b/e2e/shots/detail-mobile-375.png differ diff --git a/e2e/shots/detail-not-found.png b/e2e/shots/detail-not-found.png index 824a8ae..0b6deed 100644 Binary files a/e2e/shots/detail-not-found.png and b/e2e/shots/detail-not-found.png differ diff --git a/e2e/shots/detail-share-copied.png b/e2e/shots/detail-share-copied.png index 6dcf4a8..2dc6f7c 100644 Binary files a/e2e/shots/detail-share-copied.png and b/e2e/shots/detail-share-copied.png differ diff --git a/e2e/shots/detail.png b/e2e/shots/detail.png index 3439343..b53606a 100644 Binary files a/e2e/shots/detail.png and b/e2e/shots/detail.png differ diff --git a/e2e/shots/empty-area.png b/e2e/shots/empty-area.png index dd4bb49..54bb6c6 100644 Binary files a/e2e/shots/empty-area.png and b/e2e/shots/empty-area.png differ diff --git a/e2e/shots/english-list.png b/e2e/shots/english-list.png index 96bce26..a2064e6 100644 Binary files a/e2e/shots/english-list.png and b/e2e/shots/english-list.png differ diff --git a/e2e/shots/feedback-mobile-375.png b/e2e/shots/feedback-mobile-375.png index d0b7891..d2b8c9d 100644 Binary files a/e2e/shots/feedback-mobile-375.png and b/e2e/shots/feedback-mobile-375.png differ diff --git a/e2e/shots/feedback-open.png b/e2e/shots/feedback-open.png index 03cfcc2..7dbcbb1 100644 Binary files a/e2e/shots/feedback-open.png and b/e2e/shots/feedback-open.png differ diff --git a/e2e/shots/feedback-sent.png b/e2e/shots/feedback-sent.png index d6956b7..f0c07e8 100644 Binary files a/e2e/shots/feedback-sent.png and b/e2e/shots/feedback-sent.png differ diff --git a/e2e/shots/filters-default.png b/e2e/shots/filters-default.png index 98189fe..b88f52f 100644 Binary files a/e2e/shots/filters-default.png and b/e2e/shots/filters-default.png differ diff --git a/e2e/shots/filters-empty.png b/e2e/shots/filters-empty.png index 1136ee2..4e12923 100644 Binary files a/e2e/shots/filters-empty.png and b/e2e/shots/filters-empty.png differ diff --git a/e2e/shots/filters-greek.png b/e2e/shots/filters-greek.png index fdead0b..d147725 100644 Binary files a/e2e/shots/filters-greek.png and b/e2e/shots/filters-greek.png differ diff --git a/e2e/shots/filters-lang.png b/e2e/shots/filters-lang.png index 8470351..4c22d5f 100644 Binary files a/e2e/shots/filters-lang.png and b/e2e/shots/filters-lang.png differ diff --git a/e2e/shots/filters-mobile-375-open.png b/e2e/shots/filters-mobile-375-open.png index 0866626..4300e56 100644 Binary files a/e2e/shots/filters-mobile-375-open.png and b/e2e/shots/filters-mobile-375-open.png differ diff --git a/e2e/shots/filters-mobile-375-summary.png b/e2e/shots/filters-mobile-375-summary.png index 26850cd..455fbb0 100644 Binary files a/e2e/shots/filters-mobile-375-summary.png and b/e2e/shots/filters-mobile-375-summary.png differ diff --git a/e2e/shots/filters-mobile-375.png b/e2e/shots/filters-mobile-375.png index 283d054..b20173c 100644 Binary files a/e2e/shots/filters-mobile-375.png and b/e2e/shots/filters-mobile-375.png differ diff --git a/e2e/shots/filters-okruh.png b/e2e/shots/filters-okruh.png index a4ef79b..fd067c7 100644 Binary files a/e2e/shots/filters-okruh.png and b/e2e/shots/filters-okruh.png differ diff --git a/e2e/shots/hero-desktop.png b/e2e/shots/hero-desktop.png index c1f1b98..b645aab 100644 Binary files a/e2e/shots/hero-desktop.png and b/e2e/shots/hero-desktop.png differ diff --git a/e2e/shots/hero-mobile-375.png b/e2e/shots/hero-mobile-375.png index 78810d5..f664fc7 100644 Binary files a/e2e/shots/hero-mobile-375.png and b/e2e/shots/hero-mobile-375.png differ diff --git a/e2e/shots/kdy-impossible-muted.png b/e2e/shots/kdy-impossible-muted.png index 0f48ead..14478c0 100644 Binary files a/e2e/shots/kdy-impossible-muted.png and b/e2e/shots/kdy-impossible-muted.png differ diff --git a/e2e/shots/kdy-impossible-zitra.png b/e2e/shots/kdy-impossible-zitra.png index 5fdd3af..9fa4a00 100644 Binary files a/e2e/shots/kdy-impossible-zitra.png and b/e2e/shots/kdy-impossible-zitra.png differ diff --git a/e2e/shots/kdy-kolem-nedele.png b/e2e/shots/kdy-kolem-nedele.png index 6b906ee..c4c6114 100644 Binary files a/e2e/shots/kdy-kolem-nedele.png and b/e2e/shots/kdy-kolem-nedele.png differ diff --git a/e2e/shots/kdy-kolem.png b/e2e/shots/kdy-kolem.png index 1d6f2a6..7400fdf 100644 Binary files a/e2e/shots/kdy-kolem.png and b/e2e/shots/kdy-kolem.png differ diff --git a/e2e/shots/kdy-mobile-375.png b/e2e/shots/kdy-mobile-375.png index f3f4f6d..ba05a87 100644 Binary files a/e2e/shots/kdy-mobile-375.png and b/e2e/shots/kdy-mobile-375.png differ diff --git a/e2e/shots/kdy-vecer.png b/e2e/shots/kdy-vecer.png index 0c5fedc..6a515f8 100644 Binary files a/e2e/shots/kdy-vecer.png and b/e2e/shots/kdy-vecer.png differ diff --git a/e2e/shots/list.png b/e2e/shots/list.png index 8bf12cf..dfc9f7d 100644 Binary files a/e2e/shots/list.png and b/e2e/shots/list.png differ diff --git a/e2e/shots/loading.png b/e2e/shots/loading.png index 11f7883..4b5f835 100644 Binary files a/e2e/shots/loading.png and b/e2e/shots/loading.png differ diff --git a/e2e/shots/map-mixed-375.png b/e2e/shots/map-mixed-375.png index 558a2d4..36da806 100644 Binary files a/e2e/shots/map-mixed-375.png and b/e2e/shots/map-mixed-375.png differ diff --git a/e2e/shots/map-mixed.png b/e2e/shots/map-mixed.png index b9440dd..b980dfc 100644 Binary files a/e2e/shots/map-mixed.png and b/e2e/shots/map-mixed.png differ diff --git a/e2e/shots/map-mobile-375.png b/e2e/shots/map-mobile-375.png index a1d0101..a2b5d35 100644 Binary files a/e2e/shots/map-mobile-375.png and b/e2e/shots/map-mobile-375.png differ diff --git a/e2e/shots/map-popover-miss.png b/e2e/shots/map-popover-miss.png index b5bfdf0..a4d53f3 100644 Binary files a/e2e/shots/map-popover-miss.png and b/e2e/shots/map-popover-miss.png differ diff --git a/e2e/shots/map-popover.png b/e2e/shots/map-popover.png index 54ade05..3ff0759 100644 Binary files a/e2e/shots/map-popover.png and b/e2e/shots/map-popover.png differ diff --git a/e2e/shots/map-view.png b/e2e/shots/map-view.png index e7e301d..08e6848 100644 Binary files a/e2e/shots/map-view.png and b/e2e/shots/map-view.png differ diff --git a/e2e/shots/no-permission.png b/e2e/shots/no-permission.png index 1630731..04568b7 100644 Binary files a/e2e/shots/no-permission.png and b/e2e/shots/no-permission.png differ diff --git a/e2e/shots/offline-indicator.png b/e2e/shots/offline-indicator.png index 666cf2b..ca8db1a 100644 Binary files a/e2e/shots/offline-indicator.png and b/e2e/shots/offline-indicator.png differ diff --git a/e2e/shots/offline-last-position.png b/e2e/shots/offline-last-position.png index d5f755b..3d16439 100644 Binary files a/e2e/shots/offline-last-position.png and b/e2e/shots/offline-last-position.png differ diff --git a/e2e/shots/persona-james-english-sunday.png b/e2e/shots/persona-james-english-sunday.png index dae90c2..9ba43d2 100644 Binary files a/e2e/shots/persona-james-english-sunday.png and b/e2e/shots/persona-james-english-sunday.png differ diff --git a/e2e/shots/persona-novakovi-vecer-cesta.png b/e2e/shots/persona-novakovi-vecer-cesta.png index 7d2465a..bab6aaf 100644 Binary files a/e2e/shots/persona-novakovi-vecer-cesta.png and b/e2e/shots/persona-novakovi-vecer-cesta.png differ diff --git a/e2e/shots/persona-tomas-lunch-window.png b/e2e/shots/persona-tomas-lunch-window.png index 8970cb4..ae91d07 100644 Binary files a/e2e/shots/persona-tomas-lunch-window.png and b/e2e/shots/persona-tomas-lunch-window.png differ diff --git a/e2e/shots/persona-vera-letni-poradek.png b/e2e/shots/persona-vera-letni-poradek.png index d377e73..9a3721e 100644 Binary files a/e2e/shots/persona-vera-letni-poradek.png and b/e2e/shots/persona-vera-letni-poradek.png differ diff --git a/e2e/shots/route-back-my-location.png b/e2e/shots/route-back-my-location.png index b6e6429..a455603 100644 Binary files a/e2e/shots/route-back-my-location.png and b/e2e/shots/route-back-my-location.png differ diff --git a/e2e/shots/route-den-nedele.png b/e2e/shots/route-den-nedele.png index d3aeaec..bce7716 100644 Binary files a/e2e/shots/route-den-nedele.png and b/e2e/shots/route-den-nedele.png differ diff --git a/e2e/shots/search-church.png b/e2e/shots/search-church.png index 504c401..be3ba5b 100644 Binary files a/e2e/shots/search-church.png and b/e2e/shots/search-church.png differ diff --git a/e2e/shots/search-open.png b/e2e/shots/search-open.png index af6a454..1022037 100644 Binary files a/e2e/shots/search-open.png and b/e2e/shots/search-open.png differ diff --git a/e2e/store-shots.spec.ts b/e2e/store-shots.spec.ts index 687b053..518dfb3 100644 --- a/e2e/store-shots.spec.ts +++ b/e2e/store-shots.spec.ts @@ -52,7 +52,7 @@ for (const d of DEVICES) { // fixture at FIXED_NOW (map already captured, so tile-fade timing moot) await page.clock.setFixedTime(FIXED_NOW) await mockData(page) - await page.goto('/') + await page.goto('/?zobrazeni=seznam') await page.getByText('kostel Panny Marie Sněžné').first().waitFor() await page.evaluate(() => document.fonts.ready.then(() => undefined)) await page.screenshot({ path: `${dir}/2-home.png` }) diff --git a/ios/App/fastlane/screenshots/cs/ipad-13-1-map.png b/ios/App/fastlane/screenshots/cs/ipad-13-1-map.png index 2f92309..82e4dc2 100644 Binary files a/ios/App/fastlane/screenshots/cs/ipad-13-1-map.png and b/ios/App/fastlane/screenshots/cs/ipad-13-1-map.png differ diff --git a/ios/App/fastlane/screenshots/cs/iphone-6.9-1-map.png b/ios/App/fastlane/screenshots/cs/iphone-6.9-1-map.png index 9a26620..c58bee2 100644 Binary files a/ios/App/fastlane/screenshots/cs/iphone-6.9-1-map.png and b/ios/App/fastlane/screenshots/cs/iphone-6.9-1-map.png differ diff --git a/src/App.en.test.tsx b/src/App.en.test.tsx index e8d7b81..332c4bc 100644 --- a/src/App.en.test.tsx +++ b/src/App.en.test.tsx @@ -53,6 +53,9 @@ beforeEach(() => { vi.useFakeTimers({ now: NOW, shouldAdvanceTime: true }) stubFetch() Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) + // The map is now the default landing view when online; this journey exercises + // the seznam, so start there explicitly (mirrors App.test.tsx). + window.history.replaceState(null, '', '/?zobrazeni=seznam') }) afterEach(() => { vi.useRealTimers() diff --git a/src/App.test.tsx b/src/App.test.tsx index 9af24ba..97f3671 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -13,6 +13,10 @@ const NOW = new Date('2026-07-03T15:00:00Z') // put every "HH:MM" string into the DOM. const seznam = () => within(screen.getByTestId('seznam')) +// Assert query params individually — the URL also carries ?zobrazeni=seznam now +// that the map is the landing view, so exact-string matches on ?den/?cas are brittle. +const qp = (key: string) => new URLSearchParams(window.location.search).get(key) + const INDEX: IndexRow[] = [ ['1', 'kostel Nejsvětějšího Salvátora', 'Praha 1', 50.086, 14.417, 1, '50-14', 'https://www.farnostsalvator.cz'], ['2', 'kostel sv. Havla', 'Praha 1', 50.0855, 14.4229, 0, '50-14'], @@ -109,10 +113,14 @@ beforeEach(() => { localStorage.clear() // last-known-origin seeding must not leak across tests vi.useFakeTimers({ now: NOW, shouldAdvanceTime: true }) stubFetch() + // The map is now the default landing view; these journeys exercise the seznam, + // so start there explicitly. (The map-default is covered by its own tests below.) + window.history.replaceState(null, '', '/?zobrazeni=seznam') }) afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }) // reset offline overrides }) describe('Marie finds the nearest mass', () => { @@ -152,6 +160,26 @@ describe('Marie finds the nearest mass', () => { expect(await screen.findByText(/aktuální k 3\. 7\. 2026/)).toBeInTheDocument() }) + it('landing view: the map is the default when online (times ON the map, no tap)', async () => { + stubGeolocation('granted') + window.history.replaceState(null, '', '/') // no ?zobrazeni → the default decides + render() + // the toggle reflects the active view; map is pressed, and the seznam list + // is not rendered (assert the toggle, not leaflet internals) + const mapa = await screen.findByRole('button', { name: 'mapa' }) + expect(mapa).toHaveAttribute('aria-pressed', 'true') + expect(screen.queryByTestId('seznam')).not.toBeInTheDocument() + }) + + it('landing view: falls back to the seznam when offline (the map needs a connection)', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }) + stubGeolocation('granted') + window.history.replaceState(null, '', '/') // no ?zobrazeni → offline default = seznam + render() + expect(await screen.findByTestId('seznam')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'seznam' })).toHaveAttribute('aria-pressed', 'true') + }) + it('with location: lists nearby services soonest-first with time-until and distance', async () => { stubGeolocation('granted') render() @@ -238,7 +266,7 @@ describe('Marie finds the nearest mass', () => { it('URL routing: ?den=nedele bookmark restores the Sunday ordo; day picks write the URL', async () => { stubGeolocation('granted') - window.history.pushState(null, '', '/?den=nedele') + window.history.pushState(null, '', '/?zobrazeni=seznam&den=nedele') render() // NOW is Friday 3 Jul → "neděle" is Sunday 5 Jul, restored from the URL expect(await screen.findByText('neděle 5. 7.')).toBeInTheDocument() @@ -246,26 +274,30 @@ describe('Marie finds the nearest mass', () => { expect(screen.getByRole('button', { name: /^neděle/ })).toHaveAttribute('aria-pressed', 'true') expect(screen.queryByText(/^za \d/)).not.toBeInTheDocument() // planning view, no countdowns - // switching the day rewrites the query param (replace — no history spam) + // switching the day rewrites the query param (replace — no history spam). + // Assert the den param specifically (the URL also carries ?zobrazeni=seznam). + const den = () => new URLSearchParams(window.location.search).get('den') const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) await user.click(screen.getByRole('button', { name: 'zítra' })) - expect(window.location.search).toBe('?den=zitra') + expect(den()).toBe('zitra') await user.click(screen.getByRole('button', { name: 'hned' })) - expect(window.location.search).toBe('') + expect(den()).toBeNull() // "hned" clears the day param expect(await screen.findByText(/^za (1 h|59 min)$/)).toBeInTheDocument() }) it('URL routing: opening a detail keeps ?den, zpět restores the day view', async () => { stubGeolocation('granted') - window.history.pushState(null, '', '/?den=nedele') + window.history.pushState(null, '', '/?zobrazeni=seznam&den=nedele') const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) render() await screen.findByText('neděle 5. 7.') await user.click(screen.getAllByText('kostel Nejsvětějšího Salvátora')[0]) // two Sunday masses - expect(window.location.pathname + window.location.search).toBe('/kostel/1/?den=nedele') + expect(window.location.pathname).toBe('/kostel/1/') + expect(new URLSearchParams(window.location.search).get('den')).toBe('nedele') // ?den survives the nav await user.click(await screen.findByRole('button', { name: '‹ zpět na seznam' })) - expect(window.location.pathname + window.location.search).toBe('/?den=nedele') + expect(window.location.pathname).toBe('/') + expect(new URLSearchParams(window.location.search).get('den')).toBe('nedele') expect(await screen.findByText('neděle 5. 7.')).toBeInTheDocument() }) @@ -513,7 +545,7 @@ describe('Marie finds the nearest mass', () => { // večer: Salvátor's 18:00 stays openControls() await user.click(screen.getByRole('button', { name: 'večer' })) - expect(window.location.search).toBe('?cas=vecer') + expect(qp('cas')).toBe('vecer') expect(seznam().getByText('18:00')).toBeInTheDocument() // dopoledne is over on a Friday 17:00: the chip is muted and picking it @@ -522,7 +554,8 @@ describe('Marie finds the nearest mass', () => { expect(dopoledne).toHaveClass('opacity-40') expect(dopoledne).toHaveAttribute('title', 'dnes už proběhlo — přepne na zítra') await user.click(dopoledne) - expect(window.location.search).toBe('?cas=dopoledne&den=zitra') + expect(qp('cas')).toBe('dopoledne') + expect(qp('den')).toBe('zitra') expect(screen.getByRole('button', { name: 'zítra' })).toHaveAttribute('aria-pressed', 'true') // Saturday has no 10–13 service nearby → an honest, explained empty state expect(screen.getByText(/neodpovídá žádná bohoslužba/)).toBeInTheDocument() @@ -533,14 +566,16 @@ describe('Marie finds the nearest mass', () => { // composes with the day: v neděli kolem 9:00 → only Havel's 10:00 (±90 min) await user.click(screen.getByRole('button', { name: /^neděle/ })) fireEvent.change(screen.getByLabelText('Kolem času'), { target: { value: '09:00' } }) - expect(window.location.search).toBe('?cas=09:00&den=nedele') + expect(qp('cas')).toBe('09:00') + expect(qp('den')).toBe('nedele') expect(seznam().getAllByText('10:00').length).toBeGreaterThan(0) expect(seznam().queryByText('14:00')).not.toBeInTheDocument() // Salvátor Sunday, outside ±90 // toggle off clears the param and the sticky value await user.click(screen.getByRole('button', { name: 'hned' })) fireEvent.change(screen.getByLabelText('Kolem času'), { target: { value: '' } }) - expect(window.location.search).toBe('') + expect(qp('cas')).toBeNull() + expect(qp('den')).toBeNull() expect(localStorage.getItem('bohosluzby:cas')).toBeNull() }) @@ -553,7 +588,7 @@ describe('Marie finds the nearest mass', () => { render() await screen.findByText(/Salvátora/) // stale "večer" not re-applied → no ?cas= param, "hned" stays the active chip - expect(window.location.search).toBe('') + expect(qp('cas')).toBeNull() openControls() expect(screen.getByRole('button', { name: 'hned' })).toHaveAttribute('aria-pressed', 'true') }) @@ -704,7 +739,7 @@ describe('Marie finds the nearest mass', () => { value: { getCurrentPosition }, configurable: true, }) - window.history.pushState(null, '', '/mesto/praha/') + window.history.pushState(null, '', '/mesto/praha/?zobrazeni=seznam') render() expect(await screen.findByText('kostel Nejsvětějšího Salvátora')).toBeInTheDocument() // list header names the city diff --git a/src/App.tsx b/src/App.tsx index 67bf51f..898a8f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -284,10 +284,21 @@ export default function App() { } const setParam = (key: string, value: string | null) => setParams({ [key]: value }) const setDay = (d: DayChoice) => setParam('den', dayToParam(new Date(), d)) - // seznam · mapa — the hero list's other face, bookmarkable as ?zobrazeni=mapa - const view: 'seznam' | 'mapa' = params.get('zobrazeni') === 'mapa' ? 'mapa' : 'seznam' + const online = useSyncExternalStore(onlineStore.subscribe, onlineStore.snapshot) + // seznam · mapa. The map is the landing view — the app's edge is times shown ON + // the map without tapping. But the map needs a connection; offline (no explicit + // choice) we land on the seznam, the offline-safe path. An explicit ?zobrazeni + // always wins and round-trips. + const view: 'seznam' | 'mapa' = + params.get('zobrazeni') === 'seznam' + ? 'seznam' + : params.get('zobrazeni') === 'mapa' + ? 'mapa' + : online + ? 'mapa' + : 'seznam' const setView = (v: 'seznam' | 'mapa') => { - setParam('zobrazeni', v === 'mapa' ? 'mapa' : null) + setParam('zobrazeni', v) // record the choice explicitly so it survives a reload track('key_action', { action: 'view', view: v }) } const setCas = (c: string | null) => { @@ -397,7 +408,6 @@ export default function App() { } }, [origin]) - const online = useSyncExternalStore(onlineStore.subscribe, onlineStore.snapshot) // /mesto// — the city's centroid becomes the origin (SEO landing pages). // Leaving the city route for home (browser back, "moje poloha", zpět from a diff --git a/store-assets/ios/ipad-13/1-map.png b/store-assets/ios/ipad-13/1-map.png index 2f92309..82e4dc2 100644 Binary files a/store-assets/ios/ipad-13/1-map.png and b/store-assets/ios/ipad-13/1-map.png differ diff --git a/store-assets/ios/iphone-6.9/1-map.png b/store-assets/ios/iphone-6.9/1-map.png index 9a26620..c58bee2 100644 Binary files a/store-assets/ios/iphone-6.9/1-map.png and b/store-assets/ios/iphone-6.9/1-map.png differ