Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
0ee6d89
fix(App.tsx): dayFromParam returns 7 for Sunday slug when today is Su…
Chartres Sep 20, 2026
293174b
fix(ChurchDetail.tsx): use loadData for service shard fetch
Chartres Sep 20, 2026
c970839
fix(App.tsx): add dayFromParam Sunday key=7 round-trip test
Chartres Sep 20, 2026
4bd3bd2
fix(feedback.ts): parse one-off service date as UTC noon not local time
Chartres Sep 20, 2026
7d555e6
fix(MapView.tsx): cluster witness-filtered churches from full index
Chartres Sep 20, 2026
8a32e0e
fix(feedbackStore.ts): default CORROBORATION_MIN to 3; keep 1 for pre…
Chartres Sep 20, 2026
40ebb0b
fix(cities.ts): unexport normalizeCity, fold, slugify (internal helpers)
Chartres Sep 20, 2026
e071f16
refactor(occurrences): share pragueIsoDate instead of three inline co…
Chartres Sep 20, 2026
638e3e0
refactor(i18n): drop dead verifiedYear — the per-row marker was repla…
Chartres Sep 20, 2026
03166b5
refactor(notes): share noteRunsOn for the note-vs-Prague-date check
Chartres Sep 20, 2026
5c0e60b
fix(geo): clear deadline timer when read() wins the race
Chartres Sep 20, 2026
73eefce
fix(linkcheck-www): correct flip.example selftest assertion to drop:f…
Chartres Sep 20, 2026
5f82dd6
fix(cities): group municipalities by name+cell to prevent same-name t…
Chartres Sep 29, 2026
27a94a5
fix(ics): strip range suffix from service.time before splitting on colon
Chartres Sep 29, 2026
8be16f0
fix(liturgical): advent1 wrong when Dec 25 is Sunday — use (dow||7) t…
Chartres Sep 29, 2026
52d55d5
fix(notes): skip nthRe prose-prefix match when input contains negation
Chartres Sep 29, 2026
1223f8f
fix(routing): wrap decodeURIComponent in try/catch to prevent URIErro…
Chartres Sep 29, 2026
91247b8
fix(app): propagate shard fetch failure to dataError instead of silen…
Chartres Sep 29, 2026
0c07fea
fix(app): recompute rows on visibilitychange to refresh after resume …
Chartres Sep 29, 2026
5ad9f9b
fix(app): guard nothing-nearby empty state to day=now to prevent doub…
Chartres Sep 29, 2026
d1258d8
fix(intro): use onCloseRef to prevent focus-trap effect re-running on…
Chartres Sep 29, 2026
f941795
fix(dataStore): bypass cache when bundled snapshot is newer than cach…
Chartres Sep 29, 2026
2b16058
fix(dataStore): reset cacheReady before shard writes to prevent mixed…
Chartres Sep 29, 2026
c06085b
fix(map): debounce moveend render and cap church_id list at 500 for S…
Chartres Sep 29, 2026
734ad85
fix(map): skip selectUpcoming when >200 visible churches to prevent O…
Chartres Sep 29, 2026
6162d43
fix(detail): hide photo hero when credit is empty (CC requires attrib…
Chartres Sep 29, 2026
bf6c054
fix(photos): merge with existing photos.json instead of overwriting o…
Chartres Sep 29, 2026
11d2cdd
refactor(liturgical): reuse pragueToday instead of inline Intl date p…
Chartres Sep 29, 2026
7ae5d28
refactor(app): compute dayFromParam offset directly, drop search loop…
Chartres Sep 29, 2026
a3129c4
refactor(src): unexport six symbols only used inside their own module
Chartres Sep 29, 2026
89b7d5d
fix(notes): evaluate školní rok AFTER negation checks
Chartres Sep 29, 2026
e876eb4
fix(notes): not(nthWeekday) must not exclude non-target weekdays
Chartres Sep 29, 2026
ba1ad6e
fix(notes): combine positive segment predicates with OR not AND
Chartres Sep 29, 2026
29eb8e8
fix(prerender): map full church row including cell for slug consistency
Chartres Sep 29, 2026
8c5238f
fix(cities): merge same-municipality groups split by 1° cell boundary
Chartres Sep 29, 2026
8f4462c
fix(cities): numeric suffix for slug collisions after diacritics strip
Chartres Sep 29, 2026
9e6fa79
fix(dataStore): delete version.json before shard writes to prevent mi…
Chartres Sep 29, 2026
e3a91d5
fix(dataStore): treat corrupt cached version.json as absent
Chartres Sep 29, 2026
8d54a75
fix(dataStore): use ISO timestamp for freshness comparison to detect …
Chartres Sep 29, 2026
fa53f5c
fix(app): zpět from church detail uses history.back() to restore city…
Chartres Sep 29, 2026
da50375
fix(app): geolocation result must not overwrite a URL-picked city
Chartres Sep 29, 2026
68aad3c
fix(app): degrade gracefully when one shard fails to load
Chartres Sep 29, 2026
7ca3766
fix(map): raise marker-match threshold from 200 to 500
Chartres Sep 29, 2026
94674b2
fix(feedbackStore): deduplicate chip contributions by deviceId
Chartres Sep 29, 2026
d63e19c
refactor(app): reuse fmtWeekdayShort for day-picker labels
Chartres Sep 29, 2026
d3b531b
refactor(ranking): compare ordo target day via pragueIsoDate
Chartres Sep 29, 2026
09769a1
fix(notes): AND exclusion preds so stacked exclusions are not cancelled
Chartres Sep 29, 2026
ca6413d
fix(notes): konce-bound uses last day of month, not day 1
Chartres Sep 29, 2026
22e46d0
fix(dataStore): use timestamp for bundled-vs-cache freshness check
Chartres Sep 29, 2026
92e5184
fix(dataStore): store asOf as ISO timestamp on first launch
Chartres Sep 30, 2026
378fdbe
fix(MapView): don't cache failed shard loads
Chartres Sep 30, 2026
153c478
fix(MapView): clear shardCache when reloadKey changes after OTA refresh
Chartres Sep 30, 2026
3e3f877
fix(App): add clockTick to day memo deps to fix midnight staleness
Chartres Sep 30, 2026
88c0076
fix(App): add clockTick to DayPicker options memo deps
Chartres Sep 30, 2026
add73bd
fix(extract): apply normUrl to shard contact www values
Chartres Sep 30, 2026
5473f7a
fix(ChurchDetail): prepend https:// for scheme-less www contact values
Chartres Sep 30, 2026
16c6f93
fix(cities): stable slug collision resolution using name order
Chartres Sep 30, 2026
30dfceb
refactor(app): drop unused exports (litForChoice, parseRoute, DayChoi…
Chartres Sep 30, 2026
d39a045
refactor(map): replace witnessTiers/hasWitness with the church-tier c…
Chartres Sep 30, 2026
0f18b93
refactor(feedback): hoist the duplicated chip-order map to one constant
Chartres Sep 30, 2026
9d0844f
fix(notes): include Feb 29 in leap-year "do konce února" ranges (M2)
Chartres Sep 30, 2026
c7cb710
fix(notes): AND-combine inclusion segments instead of OR-ing them (M1)
Chartres Sep 30, 2026
ed7870e
fix(notes): flag unparsed weekday/seasonal patterns as uncertain (M3)
Chartres Sep 30, 2026
d8ca4ea
fix(dataStore): clear stale asOf when bundled snapshot supersedes cac…
Chartres Sep 30, 2026
ff03f80
fix(dataStore): hold refreshLock during OTA write phase (M4)
Chartres Sep 30, 2026
99da3fe
fix(extract): survive permanent 4xx on individual detail IDs (M6)
Chartres Sep 30, 2026
3512fba
fix(prerender): generate /kostel/<id>/ pages for crawlers (M7)
Chartres Sep 30, 2026
8dcd903
refactor(format): reuse pragueIsoDate instead of a second en-CA date …
Chartres Sep 30, 2026
1e23cc5
refactor(dataStore): web refresh reads version.json via loadData
Chartres Sep 30, 2026
0f8bb75
refactor(ics): build DTSTART date from pragueIsoDate
Chartres Sep 30, 2026
fb0fd07
fix(notes): comma-joined ordinals "1., 3. sobota" kept as one segment…
Chartres Sep 30, 2026
4822f93
fix(notes): OR same-kind inclusion segments, AND across kinds (H2)
Chartres Sep 30, 2026
e74da2e
fix(map): cluster tap never zooms out at zoom ≥18 (M3)
Chartres Sep 30, 2026
439fb12
fix(app): show "zobrazit další" on witness-filtered ordo-day views (M4)
Chartres Sep 30, 2026
59eab56
fix(feedback): explicit .limit(1000) on mass_feedback select (M5)
Chartres Sep 30, 2026
10addf9
fix(feedback): discard stale loadAggregates responses via generation …
Chartres Sep 30, 2026
5424d70
fix(feedback): check error in functions.invoke response (M7)
Chartres Sep 30, 2026
69c7170
fix(map): witness cluster membership is now pan-invariant (M8)
Chartres Sep 30, 2026
17dc07b
refactor(domain): pass services directly as occurrence specs
Chartres Sep 30, 2026
8e25964
refactor(app): read season label from i18n season_* keys
Chartres Sep 30, 2026
6c47f07
refactor(format): dayLabel reads day_today/day_tomorrow from i18n
Chartres Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions data/extract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}

Expand Down Expand Up @@ -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
Expand All @@ -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)) {
Expand Down
4 changes: 3 additions & 1 deletion data/match-photos.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>75</string>
<string>76</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
Expand Down
Binary file modified ios/App/fastlane/screenshots/cs/ipad-13-1-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ios/App/fastlane/screenshots/cs/ipad-13-2-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ios/App/fastlane/screenshots/cs/ipad-13-3-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ios/App/fastlane/screenshots/cs/iphone-6.9-1-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ios/App/fastlane/screenshots/cs/iphone-6.9-2-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ios/App/fastlane/screenshots/cs/iphone-6.9-3-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 7 additions & 5 deletions scripts/linkcheck-www.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
27 changes: 24 additions & 3 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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/<id>/ (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/<id>/)
const home = template.replace(
Expand All @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((u) => ` <url><loc>${ORIGIN}${u}</loc><lastmod>${today}</lastmod></url>`).join('\n')}
</urlset>
`
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`)
14 changes: 13 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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)
})
})
Loading
Loading