[0]>(`services/${church.cell}.json`)
.then((shard) => {
if (cancelled) return
const s = decodeShard(shard).get(church.id)
@@ -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 (
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
diff --git a/src/domain/cities.test.ts b/src/domain/cities.test.ts
index 9e56e3e..73348a0 100644
--- a/src/domain/cities.test.ts
+++ b/src/domain/cities.test.ts
@@ -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 => ({
@@ -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),
@@ -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' })
diff --git a/src/domain/cities.ts b/src/domain/cities.ts
index d17f098..9cd7e61 100644
--- a/src/domain/cities.ts
+++ b/src/domain/cities.ts
@@ -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, '')
diff --git a/src/domain/feedback.ts b/src/domain/feedback.ts
index b9d624f..a448ccd 100644
--- a/src/domain/feedback.ts
+++ b/src/domain/feedback.ts
@@ -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). */
@@ -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
@@ -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,
diff --git a/src/domain/notes.ts b/src/domain/notes.ts
index 09e9466..f715f6f 100644
--- a/src/domain/notes.ts
+++ b/src/domain/notes.ts
@@ -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. */
@@ -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)
+}
diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts
index 1d5d0bc..633896b 100644
--- a/src/domain/occurrences.ts
+++ b/src/domain/occurrences.ts
@@ -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)
diff --git a/src/domain/ranking.ts b/src/domain/ranking.ts
index a6ebb71..65b5625 100644
--- a/src/domain/ranking.ts
+++ b/src/domain/ranking.ts
@@ -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
@@ -54,7 +47,7 @@ export function rankUpcoming(
let best: Upcoming | null = null
const consider = (service: Service | ExtraService, spec: Parameters[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
@@ -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[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 })
}
}
diff --git a/src/i18n.ts b/src/i18n.ts
index b7ea3d5..62fa69b 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -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 {
diff --git a/src/lib/feedbackStore.test.ts b/src/lib/feedbackStore.test.ts
index 56c4637..76f666e 100644
--- a/src/lib/feedbackStore.test.ts
+++ b/src/lib/feedbackStore.test.ts
@@ -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 () => {
diff --git a/src/lib/feedbackStore.ts b/src/lib/feedbackStore.ts
index f081bc1..edf2be1 100644
--- a/src/lib/feedbackStore.ts
+++ b/src/lib/feedbackStore.ts
@@ -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'
diff --git a/src/lib/geo.ts b/src/lib/geo.ts
index 93cbfa0..fbdefdf 100644
--- a/src/lib/geo.ts
+++ b/src/lib/geo.ts
@@ -45,10 +45,12 @@ export async function getPermissionState(): Promise {
export async function getCurrentPosition(
opts: { timeout?: number; maximumAge?: number; deadlineMs?: number } = {},
): Promise {
- const deadline = new Promise((resolve) =>
- setTimeout(() => resolve({ coords: null, error: 'deadline' }), opts.deadlineMs ?? 10_000),
- )
+ let deadlineTimer: ReturnType
+ const deadline = new Promise((resolve) => {
+ deadlineTimer = setTimeout(() => resolve({ coords: null, error: 'deadline' }), opts.deadlineMs ?? 10_000)
+ })
const result = await Promise.race([deadline, read(opts)])
+ clearTimeout(deadlineTimer!)
if (result.error) {
// fire-and-forget telemetry: which failure class do real devices hit?
try {
diff --git a/src/lib/native-actions.ts b/src/lib/native-actions.ts
index f7abf2f..30b4eaa 100644
--- a/src/lib/native-actions.ts
+++ b/src/lib/native-actions.ts
@@ -2,8 +2,8 @@ import { isNative } from './native'
import { reminderTitle, t } from '../i18n'
import type { Church, ExtraService, Service } from '../domain/data'
import { buildICS } from '../domain/ics'
-import { nextReminderAt, pragueToday } from '../domain/occurrences'
-import { parseNote } from '../domain/notes'
+import { nextReminderAt } from '../domain/occurrences'
+import { noteRunsOn } from '../domain/notes'
import { recordExpectedAttendance } from './feedbackLedger'
import { massKey, occurrenceOf } from '../domain/feedback'
@@ -70,11 +70,9 @@ export type ReminderResult = 'scheduled' | 'denied' | 'no-upcoming' | 'unsupport
* scheduling a reminder for a mass that provably doesn't happen.
*/
export function reminderTimeFor(service: AnyService, now: Date): Date | null {
- const rule = parseNote(service.note)
- return nextReminderAt(specOf(service), now, REMINDER_LEAD_MIN, 366, (start) => {
- const w = pragueToday(start)
- return rule.runsOn(w.y, w.m, w.d)
- })
+ return nextReminderAt(specOf(service), now, REMINDER_LEAD_MIN, 366, (start) =>
+ noteRunsOn(service.note, start),
+ )
}
/**
diff --git a/vite.config.ts b/vite.config.ts
index 7f2664d..32ab5bb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -65,5 +65,8 @@ export default defineConfig({
setupFiles: ['./vitest.setup.ts'],
css: true,
include: ['src/**/*.{test,spec}.{ts,tsx}', 'data/**/*.test.mjs'],
+ // VITE_CORROBORATION_ONE=1 keeps CORROBORATION_MIN=1 in tests (prototype semantics)
+ // without enabling WITNESS_ENABLED (that's gated on VITE_WITNESS_PREVIEW/ENABLED).
+ env: { VITE_CORROBORATION_ONE: '1' },
},
})