Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
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)
})
})
11 changes: 7 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 { aggregateCities, findCity, searchPlaces, type City } from './domain/cities'
Expand Down Expand Up @@ -264,7 +264,11 @@ export function dayFromParam(now: Date, param: string | null): DayChoice {
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
if (new Date(base + off * 86_400_000).getUTCDay() === dow) {
// 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
}
}
return 'now' // unreachable — every weekday occurs within 7 days
}
Expand All @@ -282,7 +286,6 @@ function demoMass(data: { nearby: Church[]; byId: Map<string, ChurchServices> })
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),
Expand All @@ -292,7 +295,7 @@ function demoMass(data: { nearby: Church[]; byId: Map<string, ChurchServices> })
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
Expand Down
23 changes: 6 additions & 17 deletions src/ChurchDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
type ExtraService,
type Service,
} from './domain/data'
import { nextOccurrences, pragueToday, recentOccurrence } from './domain/occurrences'
import { noteUncertain, parseNote } from './domain/notes'
import { nextOccurrences, pragueIsoDate, recentOccurrence } from './domain/occurrences'
import { noteRunsOn, noteUncertain } from './domain/notes'
import { parseConfessionFromNote } from './domain/confession'
import { fmtDateCz, isStale, withReferral } from './domain/format'
import { logError, track } from './analytics'
Expand Down Expand Up @@ -108,10 +108,7 @@ function contactHref(type: string, value: string): string | null {
return null
}

const isoToday = (): string => {
const { y, m, d } = pragueToday(new Date())
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
const isoToday = (): string => pragueIsoDate(new Date())

/** Per-service actions: add to calendar (native share sheet / web download) and,
* on native only, schedule a local reminder before the next occurrence. */
Expand Down Expand Up @@ -326,8 +323,7 @@ export function ChurchDetail({
let cancelled = false
setSvc(null)
setFailed(false)
fetch(`/data/services/${church.cell}.json`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`shard ${r.status}`))))
loadData<Parameters<typeof decodeShard>[0]>(`services/${church.cell}.json`)
.then((shard) => {
if (cancelled) return
const s = decodeShard(shard).get(church.id)
Expand Down Expand Up @@ -786,16 +782,9 @@ function ServiceRow({
// Uncertain notes never mute either — they already print loud instead.
const pausedNow = (() => {
if (!s.note) return false
const rule = parseNote(s.note)
if (rule.uncertain) return false
if (noteUncertain(s.note)) return false
const upcoming = nextOccurrences({ days: s.days, time: s.time }, new Date(), 35)
return (
upcoming.length > 0 &&
upcoming.every((start) => {
const w = pragueToday(start)
return !rule.runsOn(w.y, w.m, w.d)
})
)
return upcoming.length > 0 && upcoming.every((start) => !noteRunsOn(s.note, start))
})()
return (
<div
Expand Down
2 changes: 1 addition & 1 deletion src/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export default function MapView({
// subset (the pan-invariant whole-index clustering resumes when off).
const wt = WITNESS_ENABLED ? (filters.witnessTags ?? []) : []
const clusterChurches = wt.length
? visible.filter((c) => churchHasTags(c.id, null, wt))
? churches.filter((c) => churchHasTags(c.id, null, wt))
: churches
// Cluster over ALL churches, not just the viewport subset. Bucket
// membership must not depend on the pan: a grid cell straddling the
Expand Down
30 changes: 1 addition & 29 deletions src/domain/cities.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { aggregateCities, findCity, fold, normalizeCity, searchPlaces, slugify } from './cities'
import { aggregateCities, findCity, searchPlaces } from './cities'
import type { Church } from './data'

const church = (id: string, city: string, lat = 50, lng = 14): Church => ({
Expand All @@ -11,29 +11,6 @@ const church = (id: string, city: string, lat = 50, lng = 14): Church => ({
cell: '50-14',
})

describe('normalizeCity', () => {
it('Praha districts collapse to Praha', () => {
expect(normalizeCity('Praha 1')).toBe('Praha')
expect(normalizeCity('Praha 22')).toBe('Praha')
})
it('"quarter, municipality" keeps the municipality', () => {
expect(normalizeCity('Brno-město, Brno')).toBe('Brno')
expect(normalizeCity('Kukleny, Hradec Králové')).toBe('Hradec Králové')
expect(normalizeCity('České Budějovice 3, České Budějovice')).toBe('České Budějovice')
})
it('plain names pass through', () => {
expect(normalizeCity('Frýdek-Místek')).toBe('Frýdek-Místek')
})
})

describe('slugify', () => {
it('strips diacritics and spaces', () => {
expect(slugify('Ústí nad Labem')).toBe('usti-nad-labem')
expect(slugify('Žďár nad Sázavou')).toBe('zdar-nad-sazavou')
expect(slugify('Frýdek-Místek')).toBe('frydek-mistek')
})
})

describe('aggregateCities / findCity', () => {
const index = [
church('1', 'Praha 1', 50.08, 14.42),
Expand Down Expand Up @@ -78,11 +55,6 @@ describe('searchPlaces — unified church + city typeahead', () => {
]
const cities = aggregateCities(index)

it('fold strips Czech diacritics both sides', () => {
expect(fold('České Budějovice')).toBe('ceske budejovice')
expect(fold('Týnem')).toBe('tynem')
})

it('finds cities diacritics-insensitively ("ceske" → České Budějovice)', () => {
const r = searchPlaces(cities, index, 'ceske')
expect(r[0]).toMatchObject({ kind: 'city', name: 'České Budějovice' })
Expand Down
6 changes: 3 additions & 3 deletions src/domain/cities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@
// Imported by the app AND by scripts/prerender.mjs (node runs the .ts directly).
import type { Church } from './data'

export function normalizeCity(raw: string): string {
function normalizeCity(raw: string): string {
const city = raw.includes(',') ? raw.slice(raw.lastIndexOf(',') + 1).trim() : raw.trim()
return /^Praha \d+$/.test(city) ? 'Praha' : city
}

/** Diacritics-insensitive fold: 'České' → 'ceske' (both sides of every match). */
export function fold(s: string): string {
function fold(s: string): string {
return s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
}

export function slugify(name: string): string {
function slugify(name: string): string {
return fold(name)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
Expand Down
10 changes: 2 additions & 8 deletions src/domain/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// aggregates by Mass. docs/PILGRIM-WITNESS-PLAN.md: positive-only witness chips,
// no scale, no opposite — a wall with no way to say anything unkind.

import { pragueToday } from './occurrences'
import { pragueIsoDate, pragueToday } from './occurrences'

export interface Chip {
/** Stable ascii id (stored, aggregated). */
Expand Down Expand Up @@ -94,12 +94,6 @@ function pragueIsoWeekday(when: Date): number {
return dow === 0 ? 7 : dow
}

/** ISO date ("YYYY-MM-DD") of an instant on the Prague wall clock. */
function pragueIsoDate(when: Date): string {
const { y, m, d } = pragueToday(when)
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}

/**
* Stable grouping key for a Mass. Regular masses group by weekday+time+rite+lang
* slot (so every Sunday 09:00 ordinary-form Czech is one profile, and a Latin
Expand All @@ -115,7 +109,7 @@ export function massKey(churchId: string, service: MassRef, attendedDate: Date):
/** The write-path occurrence fields for a Mass attended on `attendedDate`. */
export function occurrenceOf(service: MassRef, attendedDate: Date): Occurrence {
return {
weekday: service.date ? pragueIsoWeekday(new Date(`${service.date}T${service.time}`)) : pragueIsoWeekday(attendedDate),
weekday: service.date ? pragueIsoWeekday(new Date(`${service.date}T12:00:00Z`)) : pragueIsoWeekday(attendedDate),
time: service.time,
rite: riteOf(service),
lang: service.lang,
Expand Down
10 changes: 10 additions & 0 deletions src/domain/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// feedback card surfaces them.

import { liturgicalDay } from './liturgical'
import { pragueToday } from './occurrences'

export interface NoteRule {
/** true = may run on that Prague calendar date; false = provably does not. */
Expand Down Expand Up @@ -296,3 +297,12 @@ export function parseNote(note: string): NoteRule {

/** Should this note be rendered as a warning rubric? */
export const noteUncertain = (note: string): boolean => parseNote(note).uncertain

/** Note-aware occurrence check: does the note allow the Prague calendar day
* `start` falls on? No note → yes. (parseNote is memoized, so this is cheap
* to call once per occurrence.) */
export const noteRunsOn = (note: string | undefined, start: Date): boolean => {
if (!note) return true
const w = pragueToday(start)
return parseNote(note).runsOn(w.y, w.m, w.d)
}
6 changes: 6 additions & 0 deletions src/domain/occurrences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export function pragueToday(now: Date): { y: number; m: number; d: number } {
return { y, m, d }
}

/** ISO date ("YYYY-MM-DD") of an instant on the Prague wall clock. */
export function pragueIsoDate(when: Date): string {
const { y, m, d } = pragueToday(when)
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}

/** Minutes since midnight on the Prague wall clock. */
export function pragueMinutes(now: Date): number {
const { hh, mm } = pragueWall(now)
Expand Down
13 changes: 3 additions & 10 deletions src/domain/ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,13 @@

import { haversineKm } from './distance'
import { nextOccurrences, pragueToday } from './occurrences'
import { parseNote } from './notes'
import { noteRunsOn } from './notes'
import { applyFilters, type Filters } from './filters'
import type { Church, ChurchServices, Service, ExtraService } from './data'

/** Day-picker choice: 'now' = soonest, time then distance; 0–6 = today + offset's full ordo. */
export type DayChoice = 'now' | number

/** Note-aware occurrence check: skip dates the note provably excludes. */
const runsOn = (service: Service | ExtraService, start: Date): boolean => {
if (!service.note) return true
const w = pragueToday(start)
return parseNote(service.note).runsOn(w.y, w.m, w.d)
}

export interface Upcoming {
church: Church
distanceKm: number
Expand Down Expand Up @@ -54,7 +47,7 @@ export function rankUpcoming(
let best: Upcoming | null = null
const consider = (service: Service | ExtraService, spec: Parameters<typeof nextOccurrences>[0]) => {
for (const start of nextOccurrences(spec, now, horizonDays)) {
if (!runsOn(service, start)) continue // "kromě července a srpna" — don't lie in July
if (!noteRunsOn(service.note, start)) continue // "kromě července a srpna" — don't lie in July
if (!best || start < best.start)
best = { church, distanceKm, start, service, updated: svc.updated }
break // occurrences are sorted; the first running one is this service's best
Expand Down Expand Up @@ -95,7 +88,7 @@ export function ordoForDay(
const distanceKm = haversineKm(origin.lat, origin.lng, church.lat, church.lng)
const consider = (service: Service | ExtraService, spec: Parameters<typeof nextOccurrences>[0]) => {
for (const start of nextOccurrences(spec, now, dayOffset + 1)) {
if (onTarget(start) && runsOn(service, start))
if (onTarget(start) && noteRunsOn(service.note, start))
out.push({ church, distanceKm, start, service, updated: svc.updated })
}
}
Expand Down
6 changes: 0 additions & 6 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,12 +462,6 @@ export function reminderScheduledMsg(min: number): string {
return lang() === 'cs' ? `✓ připomeneme ${min} min předem` : `✓ we'll remind you ${min} min before`
}

/** " · ověřeno 2016" / " · verified 2016" — the list row's stale-data marker. */
export function verifiedYear(iso: string): string {
const y = iso.slice(0, 4)
return lang() === 'cs' ? `ověřeno ${y}` : `verified ${y}`
}

/** Season-window advisory: one banner over the list (and in the route sheet)
* during the windows when parishes actually shuffle schedules. */
export function verifyBanner(season: 'summer' | 'advent' | 'christmas' | 'lent' | 'easter'): string {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/feedbackStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('aggregateFor', () => {
sub('c1|w7|09:30', ['hluboky_prozitek'])
const agg = (await aggAfterLoad('c1')).get('c1|w7|09:30')!
expect(agg.chips.some((c) => c.id === 'krasny_zpev')).toBe(false)
expect(CORROBORATION_MIN).toBe(1) // local prototype value
expect(CORROBORATION_MIN).toBeGreaterThanOrEqual(1) // prototype: 1; prod default: 3
})

it('keeps masses and churches separate; ignores other churches', async () => {
Expand Down
14 changes: 10 additions & 4 deletions src/lib/feedbackStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ import { supabase } from './supabase'
import { resolveIds } from '../platform/flywheel-client'

// A tag appears on the detail page only after this many independent witnesses.
// Prod value is 3 (a single device publishes nothing). Local prototype uses 1
// so the owner sees their own submissions while toying.
// TODO(prod): set CORROBORATION_MIN = 3
export const CORROBORATION_MIN = 1
// Prototype/preview builds (VITE_WITNESS_PREVIEW=1) use 1 so the owner sees
// their own submission while toying; all other builds require 3.
// VITE_CORROBORATION_ONE=1 is a separate test/dev override that keeps min=1
// without enabling the full WITNESS_ENABLED flag (set in vite.config test.env).
// ponytail: single-expression guard; upgrade to env-config if more tiers needed
export const CORROBORATION_MIN =
import.meta.env.VITE_WITNESS_PREVIEW === '1' ||
import.meta.env.VITE_CORROBORATION_ONE === '1'
? 1
: 3

const STORE_KEY = 'bohosluzby:massFeedback'
const SUGGEST_KEY = 'bohosluzby:tagSuggestions'
Expand Down
Loading
Loading