diff --git a/data/extract.mjs b/data/extract.mjs index 906b2c5..b0c6814 100644 --- a/data/extract.mjs +++ b/data/extract.mjs @@ -115,13 +115,18 @@ async function details(ids) { for (;;) { const id = queue.shift() if (id === undefined) return - const text = await fetchPolite(`${BASE}detail?id=${id}`) - JSON.parse(text) - writeFileSync(`${CACHE}/detail/${id}.json`, text) + try { + const text = await fetchPolite(`${BASE}detail?id=${id}`) + JSON.parse(text) + writeFileSync(`${CACHE}/detail/${id}.json`, text) + } catch (err) { + // M6: log individual 4xx/parse failures without aborting the whole batch + process.stderr.write(` detail ${id}: ${err.message}\n`) + } if (++done % 200 === 0) process.stderr.write(` detail ${done}/${queue.length + done}\n`) } } - await Promise.all(Array.from({ length: 4 }, worker)) + await Promise.allSettled(Array.from({ length: 4 }, worker)) process.stderr.write(`details: ${ids.length} churches cached\n`) } @@ -202,7 +207,9 @@ function transform() { p: inst.institution_parish_name ?? '', pa: inst.institution_parish_address ?? '', // parish-level contacts only; named persons are deliberately not published - c: (d.contacts ?? []).filter((c) => c.type && c.contact).map((c) => [c.type, c.contact]), + // normUrl fixes typo'd schemes (http:\\, http:/) for www contacts. + c: (d.contacts ?? []).filter((c) => c.type && c.contact).map((c) => + [c.type, c.type === 'www' ? (normUrl(c.contact) || c.contact) : c.contact]), s: regular.map(svcRow), } // the detail page's Farnost section reads the shard contacts — surface the @@ -218,7 +225,7 @@ function transform() { // and shows `generated` as the "aktuální k …" date. (src/lib/dataStore.ts) writeFileSync( `${OUT}/version.json`, - JSON.stringify({ generated: new Date().toISOString().slice(0, 10), churches: index.length }), + JSON.stringify({ generated: new Date().toISOString().slice(0, 10), timestamp: new Date().toISOString(), churches: index.length }), ) let total = statSync(`${OUT}/churches.json`).size for (const [cell, data] of Object.entries(shards)) { diff --git a/data/match-photos.mjs b/data/match-photos.mjs index af303bd..c233213 100644 --- a/data/match-photos.mjs +++ b/data/match-photos.mjs @@ -179,7 +179,9 @@ async function main() { if (args.cell) churches = churches.filter((c) => c.cell.startsWith(args.cell)) churches = churches.slice(0, args.limit) - const photos = {} + // Load existing results so --cell / --limit runs add to, not replace, the file. + let photos = {} + try { photos = JSON.parse(readFileSync(`${root}public/data/photos.json`, 'utf8')) } catch { /* new file */ } const licenses = {} let processed = 0, matched = 0, hadCandidate = 0 for (const c of churches) { diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index ed9190b..35c956b 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -296,7 +296,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = 8L39RHYGVY; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Bohoslužby"; @@ -321,7 +321,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = 8L39RHYGVY; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Bohoslužby"; diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index ebcdda8..b5c6952 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -26,7 +26,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 75 + 76 LSRequiresIPhoneOS UILaunchStoryboardName 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 f1761e9..6c034a7 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/ipad-13-2-home.png b/ios/App/fastlane/screenshots/cs/ipad-13-2-home.png index d0af34b..7d86814 100644 Binary files a/ios/App/fastlane/screenshots/cs/ipad-13-2-home.png and b/ios/App/fastlane/screenshots/cs/ipad-13-2-home.png differ diff --git a/ios/App/fastlane/screenshots/cs/ipad-13-3-detail.png b/ios/App/fastlane/screenshots/cs/ipad-13-3-detail.png index 57dd9cd..1a60d6b 100644 Binary files a/ios/App/fastlane/screenshots/cs/ipad-13-3-detail.png and b/ios/App/fastlane/screenshots/cs/ipad-13-3-detail.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 b407b80..7256521 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/ios/App/fastlane/screenshots/cs/iphone-6.9-2-home.png b/ios/App/fastlane/screenshots/cs/iphone-6.9-2-home.png index 5589eb2..3f00a51 100644 Binary files a/ios/App/fastlane/screenshots/cs/iphone-6.9-2-home.png and b/ios/App/fastlane/screenshots/cs/iphone-6.9-2-home.png differ diff --git a/ios/App/fastlane/screenshots/cs/iphone-6.9-3-detail.png b/ios/App/fastlane/screenshots/cs/iphone-6.9-3-detail.png index 1c39413..52caf7c 100644 Binary files a/ios/App/fastlane/screenshots/cs/iphone-6.9-3-detail.png and b/ios/App/fastlane/screenshots/cs/iphone-6.9-3-detail.png differ diff --git a/scripts/linkcheck-www.mjs b/scripts/linkcheck-www.mjs index 3e12a8a..797ba1e 100644 --- a/scripts/linkcheck-www.mjs +++ b/scripts/linkcheck-www.mjs @@ -189,12 +189,14 @@ async function selftest() { let m = 0 mock(async () => { m++ - // flip-flops between two different hard-failure codes across rounds — - // the real-world case that motivated 3 rounds over 2 (still all hard, - // still drops, but proves mixed failure kinds are handled) - return m % 2 ? { ok: false, status: 401 } : { ok: false, status: 456 } + // flip-flops between 401 (KEEP_STATUS — alive but blocking bots) and 456 + // across rounds; this is the real-world case that motivated 3 rounds. + // checkOnce tries HEAD then GET within a round, so group calls in pairs: + // rounds 1,3 → 401 (not a hard fail); round 2 → 456 (hard fail). + // [false, true, false].every(isHardFail) === false → keep the link. + return Math.ceil(m / 2) % 2 !== 0 ? { ok: false, status: 401 } : { ok: false, status: 456 } }) - assert.equal((await checkUrl('https://flip.example')).drop, true, 'alternating hard failures still drop after 3/3') + assert.equal((await checkUrl('https://flip.example')).drop, false, '401/456 flip-flop: 401 is KEEP_STATUS so not all rounds hard-fail → keep') mock(async () => { throw Object.assign(new Error('timeout'), { name: 'TimeoutError' }) diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 46205ee..e10a754 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -14,7 +14,7 @@ const CITY_PAGES = 30 const MAX_CHURCH_LINKS = 60 const index = JSON.parse(readFileSync(`${root}public/data/churches.json`, 'utf8')).map( - ([id, name, city, lat, lng]) => ({ id, name, city, lat, lng }), + ([id, name, city, lat, lng, barrierFree, cell, www]) => ({ id, name, city, lat, lng, barrierFree: !!barrierFree, cell, www }), ) const cities = aggregateCities(index).slice(0, CITY_PAGES) @@ -72,6 +72,23 @@ for (const city of cities) { writeFileSync(`${root}dist/mesto/${city.slug}/index.html`, html) } +// church pages — prerender each church linked from city pages so crawlers +// don't hit 404 on /kostel// (M7) +const linkedChurches = new Map() +for (const city of cities) { + for (const c of city.churches.slice(0, MAX_CHURCH_LINKS)) { + if (!linkedChurches.has(c.id)) linkedChurches.set(c.id, { ...c, cityName: city.name }) + } +} +for (const [id, c] of linkedChurches) { + const url = `${ORIGIN}/kostel/${id}/` + const title = `${c.name} — bohoslužby, ${c.cityName}` + const description = `Pořad bohoslužeb: ${c.name}, ${c.cityName}. Aktuální časy mší z rejstříku ČBK.` + const html = withMeta(template, { title, description, url }) + mkdirSync(`${root}dist/kostel/${id}`, { recursive: true }) + writeFileSync(`${root}dist/kostel/${id}/index.html`, html) +} + // home page: inject the city index into its static seo block, then mirror it // to 404.html (GH Pages deep-link fallback for /kostel//) const home = template.replace( @@ -86,11 +103,15 @@ writeFileSync(`${root}dist/index.html`, home) copyFileSync(`${root}dist/index.html`, `${root}dist/404.html`) const today = new Date().toISOString().slice(0, 10) -const urls = ['/', ...cities.map((c) => `/mesto/${c.slug}/`)] +const urls = [ + '/', + ...cities.map((c) => `/mesto/${c.slug}/`), + ...[...linkedChurches.keys()].map((id) => `/kostel/${id}/`), +] const sitemap = ` ${urls.map((u) => ` ${ORIGIN}${u}${today}`).join('\n')} ` writeFileSync(`${root}dist/sitemap.xml`, sitemap) -console.log(`prerendered ${cities.length} city pages + sitemap.xml + 404.html`) +console.log(`prerendered ${cities.length} city pages + ${linkedChurches.size} church pages + sitemap.xml + 404.html`) diff --git a/src/App.test.tsx b/src/App.test.tsx index 1f57521..a62e8cd 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -7,7 +7,7 @@ import { vi } from 'vitest' vi.mock('./lib/supabase', () => ({ supabase: null })) import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import App, { dayOptions } from './App' +import App, { dayOptions, dayFromParam, dayToParam } from './App' import type { IndexRow } from './domain/data' // Friday 3 Jul 2026 17:00 Prague. Vitest fake timers pin "now". @@ -867,3 +867,15 @@ describe('dayOptions', () => { expect(dayOptions(friday).at(-1)?.key).toBe(6) }) }) + +describe('dayFromParam / dayToParam Sunday round-trip', () => { + it('key=7 round-trips through ?den=nedele on a Sunday', () => { + const sunday = new Date('2026-07-05T10:00:00Z') // UTC Sunday + expect(dayToParam(sunday, 7)).toBe('nedele') + expect(dayFromParam(sunday, 'nedele')).toBe(7) + }) + it('?den=nedele on a non-Sunday resolves to the next Sunday offset', () => { + const friday = new Date('2026-07-03T10:00:00Z') // Friday; next Sunday = off 2 + expect(dayFromParam(friday, 'nedele')).toBe(2) + }) +}) diff --git a/src/App.tsx b/src/App.tsx index b80ee3d..c7c2cfa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,9 +12,9 @@ import { import { MAX_KM_OPTIONS, NO_FILTERS, type Filters } from './domain/filters' import { haversineKm } from './domain/distance' import { selectUpcoming, type DayChoice, type Upcoming } from './domain/ranking' -import { pragueToday } from './domain/occurrences' +import { pragueIsoDate, pragueToday } from './domain/occurrences' import { currentLiturgicalDay, liturgicalDay, verifySeason, type LiturgicalDay } from './domain/liturgical' -import { fmtDistance, fmtTime, fmtUntil, dayLabel } from './domain/format' +import { fmtDistance, fmtTime, fmtUntil, dayLabel, fmtWeekdayShort } from './domain/format' import { aggregateCities, findCity, searchPlaces, type City } from './domain/cities' import { BANDS, bandFullyPast, bandLabel, halfHoursFrom, parseCas, resolveCasDay, type Band } from './domain/timeband' import { ChurchDetail, Chip, NoteText } from './ChurchDetail' @@ -71,24 +71,6 @@ const fmtDataDate = (iso: string) => { // Leaflet + tiles code-split behind the "mapa" toggle — the list path pays nothing. const MapView = lazy(() => import('./MapView')) -const SEASON_LABEL_CS: Record = { - ordinary: 'liturgické mezidobí', - advent: 'doba adventní', - christmas: 'doba vánoční', - lent: 'doba postní', - easter: 'doba velikonoční', -} -const SEASON_LABEL_EN: Record = { - ordinary: 'ordinary time', - advent: 'Advent', - christmas: 'Christmas season', - lent: 'Lent', - easter: 'Easter season', -} -// Read per call (not a module-level constant) — a test flipping -// navigator.language must see the new language on the next render. -const seasonLabel = (s: LiturgicalDay['season']): string => - (lang() === 'cs' ? SEASON_LABEL_CS : SEASON_LABEL_EN)[s] const SEASON_VAR: Record = { green: 'var(--color-season-green)', violet: 'var(--color-season-violet)', @@ -170,15 +152,8 @@ function loadFilters(): Filters { // ---- Day picker: 'now' = soonest you can make; 0–6 = the day's full ordo ---- -export type { DayChoice } - -const WEEKDAY_SHORT_CS = ['ne', 'po', 'út', 'st', 'čt', 'pá', 'so'] // Date.getUTCDay order -const WEEKDAY_SHORT_EN = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] -/** Read per call — see seasonLabel above for why this isn't a plain constant. */ -const weekdayShort = (dow: number): string => (lang() === 'cs' ? WEEKDAY_SHORT_CS : WEEKDAY_SHORT_EN)[dow] - /** The liturgical day for a day-picker choice ('now' = today). */ -export function litForChoice(now: Date, day: DayChoice): LiturgicalDay { +function litForChoice(now: Date, day: DayChoice): LiturgicalDay { const today = pragueToday(now) const t = new Date(Date.UTC(today.y, today.m - 1, today.d) + (day === 'now' ? 0 : day) * 86_400_000) return liturgicalDay(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate()) @@ -196,8 +171,9 @@ export function dayOptions(now: Date): { key: DayChoice; label: string; lit: Lit { key: 1, label: t('day_tomorrow'), lit: litForChoice(now, 1) }, ] for (let off = 2; off <= 6; off++) { - const dow = new Date(base + off * 86_400_000).getUTCDay() - out.push({ key: off, label: dow === 0 ? t('day_sunday_full') : weekdayShort(dow), lit: litForChoice(now, off) }) + const date = new Date(base + off * 86_400_000) + const label = date.getUTCDay() === 0 ? t('day_sunday_full') : fmtWeekdayShort(date) + out.push({ key: off, label, lit: litForChoice(now, off) }) } // ON a Sunday the 0..6 window holds no future neděle — but Sunday evening IS // when next week gets planned. Offer next Sunday explicitly (audit finding). @@ -210,11 +186,13 @@ export function dayOptions(now: Date): { key: DayChoice; label: string; lit: Lit // Minimal history routing (GH Pages serves 404.html = the app for deep links). type Route = { view: 'home' } | { view: 'church'; id: string } | { view: 'city'; slug: string } -export function parseRoute(path: string): Route { +const safeDecode = (s: string) => { try { return decodeURIComponent(s) } catch { return s } } + +function parseRoute(path: string): Route { const kostel = /^\/kostel\/([^/]+)\/?$/.exec(path) - if (kostel) return { view: 'church', id: decodeURIComponent(kostel[1]) } + if (kostel) return { view: 'church', id: safeDecode(kostel[1]) } const mesto = /^\/mesto\/([^/]+)\/?$/.exec(path) - if (mesto) return { view: 'city', slug: decodeURIComponent(mesto[1]) } + if (mesto) return { view: 'city', slug: safeDecode(mesto[1]) } return { view: 'home' } } @@ -262,11 +240,11 @@ export function dayFromParam(now: Date, param: string | null): DayChoice { const dow = DAY_SLUGS.indexOf(param ?? '') if (dow === -1) return 'now' const today = pragueToday(now) - const base = Date.UTC(today.y, today.m - 1, today.d) - for (let off = 0; off <= 6; off++) { - if (new Date(base + off * 86_400_000).getUTCDay() === dow) return off - } - return 'now' // unreachable — every weekday occurs within 7 days + const todayDow = new Date(Date.UTC(today.y, today.m - 1, today.d)).getUTCDay() + const off = (dow - todayDow + 7) % 7 + // off=0 means today already is that weekday; dayToParam(0) → 'dnes', not a slug, + // so a slug for today's weekday can only mean "next week" (key=7, Sunday-only). + return off === 0 ? 7 : off } // Prototype flag (TestFlight builds only, never production): force-shows the @@ -282,7 +260,6 @@ function demoMass(data: { nearby: Church[]; byId: Map }) const svc = data.byId.get(c.id)?.regular[0] if (!svc) continue const weekday = Number(svc.days[0]) - const { y, m, d } = pragueToday(new Date()) return { churchId: c.id, massKey: slotKey(c.id, weekday, svc.time, riteOf(svc), svc.lang), @@ -292,7 +269,7 @@ function demoMass(data: { nearby: Church[]; byId: Map }) time: svc.time, rite: riteOf(svc), lang: svc.lang, - massDate: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`, + massDate: pragueIsoDate(new Date()), } } return null @@ -301,6 +278,7 @@ function demoMass(data: { nearby: Church[]; byId: Map }) export default function App() { const [index, setIndex] = useState(null) const [dataError, setDataError] = useState(false) + const [partialData, setPartialData] = useState(false) const [geoDenied, setGeoDenied] = useState(false) const [geoPrompting, setGeoPrompting] = useState(false) // browser permission dialog pending const [geoFail, setGeoFail] = useState(null) // why — picks the guidance @@ -337,7 +315,9 @@ export default function App() { const params = new URLSearchParams(search) const feedbackParam = params.get('feedback') const den = params.get('den') - const day = useMemo(() => dayFromParam(new Date(), den), [den]) + // clockTick declared here (before day) so day's memo can list it as a dep. + const [clockTick, setClockTick] = useState(0) + const day = useMemo(() => dayFromParam(new Date(), den), [den, clockTick]) const cas = parseCas(params.get('cas')) const setParams = (entries: Record) => { const p = new URLSearchParams(search) @@ -448,7 +428,11 @@ export default function App() { getCurrentPosition({ deadlineMs: perm === 'prompt' ? 30_000 : 10_000 }).then((r) => { setLocating(false) setGeoPrompting(false) - if (r.coords) setOrigin({ lat: r.coords.lat, lng: r.coords.lng, source: 'geo' }) + if (r.coords) { + const { lat, lng } = r.coords + // Don't overwrite a city the user already picked via URL while geolocation was pending. + setOrigin((cur) => cur?.source === 'city' ? cur : { lat, lng, source: 'geo' }) + } else fallback(r.error ?? 'timeout') }) }) @@ -502,6 +486,7 @@ export default function App() { if (!index || !origin) return let cancelled = false setData(null) + setPartialData(false) const nearby = index .map((c) => ({ c, d: haversineKm(origin.lat, origin.lng, c.lat, c.lng) })) .filter(({ d }) => d <= NEARBY_KM) @@ -509,9 +494,14 @@ export default function App() { .slice(0, NEARBY_CAP) .map(({ c }) => c) const cells = [...new Set(nearby.map((c) => c.cell))] + let shardFailed = false Promise.all( cells.map((cell) => - loadData[0]>(`services/${cell}.json`).catch(() => ({})), + loadData[0]>(`services/${cell}.json`).catch((err) => { + logError(err, { where: 'load-shard', cell }) + shardFailed = true + return {} + }), ), ) .then((shards) => { @@ -519,6 +509,7 @@ export default function App() { const byId = new Map() for (const shard of shards) for (const [id, s] of decodeShard(shard)) byId.set(id, s) setData({ nearby, byId }) + if (shardFailed) setPartialData(true) // show what loaded; a banner warns of missing data }) .catch((err) => { logError(err, { where: 'load-shards' }) @@ -537,6 +528,15 @@ export default function App() { setListLimit(LIST_LIMIT) // a new context restarts the cap }, [origin, filters, cas, day]) + // clockTick bumps when the page becomes visible again (resume / cross-midnight) + // so selectUpcoming re-runs with a fresh new Date(). Declared above (before the + // day memo) so it can be included in that memo's dep array. + useEffect(() => { + const onVisible = () => { if (document.visibilityState === 'visible') setClockTick((n) => n + 1) } + document.addEventListener('visibilitychange', onVisible) + return () => document.removeEventListener('visibilitychange', onVisible) + }, []) + // Witness aggregates for the nearby churches — loaded whenever a list is up so // the rows can carry the quiet witness mark, and so the "Ohlasy poutníků" // filter has data to narrow on. aggTick bumps when a load resolves so the row @@ -566,8 +566,8 @@ export default function App() { return all .filter((u) => churchHasTags(u.church.id, massKey(u.church.id, u.service, u.start), witnessTags)) .slice(0, listLimit) - // eslint-disable-next-line react-hooks/exhaustive-deps -- aggTick refreshes the aggregate reads - }, [data, origin, filters, day, cas, listLimit, witnessTags, aggTick]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- aggTick/clockTick refresh reads without stable deps + }, [data, origin, filters, day, cas, listLimit, witnessTags, aggTick, clockTick]) // Churches that carry corroborated (thresholded) church-wide witness tags — // the set the list rows mark with a quiet rubric sign. Read from the same @@ -750,12 +750,17 @@ export default function App() { {t('data_error')}

)} + {partialData && ( +

+ {t('partial_data')} +

+ )} {!dataError && route.view === 'church' && index && ( navigate(`/${search}`)} + onBack={() => history.back()} onHelp={() => setIntroOpen(true)} onHeroChange={setDetailHero} /> @@ -844,7 +849,7 @@ export default function App() { )} - {!dataError && !picking && !loading && origin && rows && rows.length === 0 && !anyFilter && index && ( + {!dataError && !picking && !loading && origin && rows && rows.length === 0 && !anyFilter && day === 'now' && index && (

{t('nothing_nearby_title')}

@@ -909,6 +914,7 @@ export default function App() { onChange={updateFilters} langs={langs} onReset={resetAll} + clockTick={clockTick} /> {/* not on a live fix (offline / last-known / picked city): search is the main CTA — a visible input-shaped button, not a buried "změnit" link */} @@ -949,6 +955,7 @@ export default function App() { onOpen={openChurch} onNavigate={setNavTarget} fill={mapMode} + reloadKey={reloadKey} /> @@ -970,7 +977,7 @@ export default function App() { onNavigate={setNavTarget} /> {/* the cap is honest: another page of the ordo instead of "evening ends at 18:00" */} - {day === 'now' && rows.length >= listLimit && ( + {(day === 'now' || witnessTags.length > 0) && rows.length >= listLimit && (