Skip to content
Merged
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
40 changes: 27 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad

## [Unreleased]

### Added
- **Canadian French (`fr-CA`) on the booker-facing surfaces.** A visitor whose browser asks
for `fr-CA` now gets Canadian French rather than the France copy; `fr` and `fr-FR` are
unaffected. It is the first regional locale, and a separate file rather than a fallback
because the differences are real: `courriel` rather than `e-mail`, `reporter`/`report`
rather than `reprogrammer`, `renseignements personnels` (the Quebec statutory term) rather
than `données personnelles`, no space before `!` `?` `;` where France puts one, and CLDR
itself spells July `juill.` here against `juil.` in France.

⚠️ **The wording is an unreviewed draft**, like every non-English locale in this
repository: the structure is verified by the same three guards (same keys, printf-verb
parity, date tables cross-checked against CLDR), but no native Canadian French speaker has
read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING.

- **`FRAME_ANCESTORS`: embed the admin UI in your own console.** Space-separated origins
(`https://console.example.com 'self'`); when set, `/admin/` sends
`Content-Security-Policy: frame-ancestors <list>`. The public booking pages are
untouched and still deny framing outright — this is about the console, not the pages
that take card details.

Two deliberate refusals. An entry that is not `https://host[:port]` or `'self'` stops
the app booting rather than being ignored, because a browser drops a source list it
cannot parse, which would leave the admin UI *more* embeddable than the setting being
unset. And no `X-Frame-Options` is sent beside it: that header has no allow-list form,
so the only value it could carry is `SAMEORIGIN`, which browsers honour instead of the
CSP and would break the embedding this exists for.

### Fixed
- **The Zoom setup text no longer promises that an unpublished app works for "your own
team".** Zoom only lets users inside the Zoom account that owns an unpublished app
Expand Down Expand Up @@ -50,19 +77,6 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
pool could satisfy are all excluded, so the explanation never appears attached to the
wrong cause. Three new/changed keys in all eight locales.

- **`FRAME_ANCESTORS`: embed the admin UI in your own console.** Space-separated origins
(`https://console.example.com 'self'`); when set, `/admin/` sends
`Content-Security-Policy: frame-ancestors <list>`. The public booking pages are
untouched and still deny framing outright — this is about the console, not the pages
that take card details.

Two deliberate refusals. An entry that is not `https://host[:port]` or `'self'` stops
the app booting rather than being ignored, because a browser drops a source list it
cannot parse, which would leave the admin UI *more* embeddable than the setting being
unset. And no `X-Frame-Options` is sent beside it: that header has no allow-list form,
so the only value it could carry is `SAMEORIGIN`, which browsers honour instead of the
CSP and would break the embedding this exists for.

### Fixed
- **Constraint violations are recognised by SQLite's error code rather than by its
English message.** Thirteen call sites asked `strings.Contains(err.Error(), "UNIQUE
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ no string plumbing with the Go templates), and admin-authored content (event nam
descriptions, questions, custom email copy). Locale is resolved per request from
`Accept-Language` + a `?lang=` override + the operator's fallback setting
(`internal/handler/i18n.go`), and the booker's locale is stored on the booking so later
reminders match. Ships `en es fr de it pt nl sv`. Full detail: ARCHITECTURE §23.
reminders match. Ships `en es fr fr-CA de it pt nl sv`. Full detail: ARCHITECTURE §23.

**Adding a locale = adding `internal/i18n/locales/<code>.json`.** Nothing else. `init()`
globs the directory; the switcher, the fallback dropdown and the public API payload all read
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ prefer additive, nullable/defaulted columns.

## Translations

Calnode ships 8 locales (`en es fr de it pt nl sv`) across the booker-facing surfaces:
Calnode ships 9 locales (`en es fr fr-CA de it pt nl sv`) across the booker-facing surfaces:
booking page, manage/reschedule page, embed widget, the four emails, and the calendar
invite. The admin UI and the built-in video room are English-only.

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,12 @@ domains, Resend email, Google & Microsoft OAuth, Litestream backups, troubleshoo
- **Zoom** — per-host OAuth; a Zoom-located booking mints a meeting under the assigned host's account
- **Built-in video meetings (LiveKit)** — in-browser rooms as a booking location (no app or account for guests); host controls (end-for-all, hand-off **and reclaim** host, attendee screen-share toggle), **meeting recording** straight to your own **Litestream backup bucket** (the same one
you already use for DB backup — no extra storage to provision) with in-app downloads, **recording consent** (notice + consent-or-leave), and an **AI notetaker** (Deepgram transcript → LLM notes). Headless-consumable: MCP `get_meeting_notes`/`get_transcript` + `recording.completed`/`transcript.ready`/`notes.ready` webhooks. BYO LiveKit endpoint (Cloud or self-hosted); configured in Settings → Video — see [docs/VIDEO.md](docs/VIDEO.md)
- **8 languages** on every booker-facing surface - booking page, manage/reschedule page,
- **9 languages** on every booker-facing surface - booking page, manage/reschedule page,
embed widget, all four emails, and the calendar invite: **English · Spanish · French ·
German · Italian · Portuguese · Dutch · Swedish**. Picked from `Accept-Language` with a
footer switcher and an operator-set fallback language; the booker's choice is stored on
the booking, so reminders arrive in the language they booked in. Adding a language is
adding one JSON file - no code change. *(The admin UI and the built-in video room are
Canadian French · German · Italian · Portuguese · Dutch · Swedish**. Picked from
`Accept-Language` with a footer switcher and an operator-set fallback language; the
booker's choice is stored on the booking, so reminders arrive in the language they
booked in. Adding a language is adding one JSON file - no code change. *(The admin UI and the built-in video room are
English-only. Non-English translations are machine drafts without native review -
corrections by PR are very welcome.)*
- Embeddable booking widget (Shadow-DOM web component; inline + popup)
Expand Down
15 changes: 13 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,7 @@ as the desired state:
patching. The **public** booking surfaces are unaffected and are verified on mobile;
this is admin-only. Deferred 2026-08-22, not a regression.
- **LiveKit room is not translated** - the booking surfaces, emails and calendar invites
ship in 8 languages (§23), but the in-browser meeting UI is ~45 hardcoded English
ship in 9 languages (§23), but the in-browser meeting UI is ~45 hardcoded English
strings. It has its own vanilla-JS asset pipeline and shares no string plumbing with
the Go templates, so it needs a small runtime `t()` of its own. Separate work, not hard.
- **Plural rules are 2-form only** (§23) - shipping Polish, Russian or Arabic correctly
Expand Down Expand Up @@ -1079,7 +1079,18 @@ LLM summary) — the next build; consent-gated (§8.11/§15 of the PRD).

## 23. Languages (i18n)

Calnode ships **8 locales**: `en` (source) · `es` · `fr` · `de` · `it` · `pt` · `nl` · `sv`.
Calnode ships **9 locales**: `en` (source) · `es` · `fr` · `fr-CA` · `de` · `it` · `pt` · `nl` · `sv`.

`fr-CA` is the first **regional** locale, and it is a separate file rather than a fallback
because the differences are real: `courriel` not `e-mail`, `reporter`/`report` not
`reprogrammer`/`reprogrammation`, `renseignements personnels` never `données personnelles`
(the Quebec statutory term), no space before `!` `?` `;` where France puts one, and CLDR
itself disagrees on one abbreviation — `month_short_jul` is `juill.` in fr-CA and `juil.` in
fr, which is exactly what `TestDateTablesMatchCLDR` exists to catch. Both keep the 24-hour
clock and the day-month `date_format`. Currency and percent take a non-breaking space before
`$` and `%` in Canadian French; no key carries either today, so the rule is recorded here
rather than applied. A visitor sending `fr-FR` or plain `fr` is unaffected — the matcher
picks the exact tag first (pinned in `TestResolve`).

### What is translated, and what is not

Expand Down
7 changes: 7 additions & 0 deletions internal/i18n/i18n_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ func TestResolve(t *testing.T) {
{"exact es", "es", "", "es"},
{"exact en", "en-US,en;q=0.9", "", "en"},
{"regional subtag falls back to primary", "es-MX,es;q=0.9", "", "es"},
// fr-CA is shipped as its own file, so a Canadian browser gets Canadian French
// rather than the France copy — and a French browser is unaffected by its
// existence, which is the half that would break silently.
{"Canadian French resolves to its own locale", "fr-CA,fr;q=0.9", "", "fr-CA"},
{"European French is unaffected by fr-CA", "fr-FR,fr;q=0.9", "", "fr"},
{"plain fr is unaffected by fr-CA", "fr", "", "fr"},
{"fr-CA can be selected by the ?lang override", "en", "fr-CA", "fr-CA"},
{"unsupported language falls back to English", "ja-JP,ja;q=0.9", "", "en"},
{"empty header falls back to English", "", "", "en"},
{"garbage header falls back to English", "not a real header ;;;", "", "en"},
Expand Down
160 changes: 160 additions & 0 deletions internal/i18n/locales/fr-CA.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
{
"book_by_chat": "Réserver par chat",
"close": "Fermer",
"assistant_greeting": "Bonjour! Dites-moi approximativement quand vous souhaitez nous rencontrer, par exemple « mardi après-midi » ou « la semaine prochaine », et je trouverai un créneau. Vous pouvez aussi utiliser le calendrier.",
"assistant_input_placeholder": "Indiquez vos disponibilités…",
"assistant_input_aria": "Envoyer un message à l'assistant de réservation",
"send": "Envoyer",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"date_picker_aria": "Sélecteur de date",
"timezone_aria": "Fuseau horaire",
"select_day_hint": "Sélectionnez un jour pour voir les créneaux disponibles.",
"back": "Retour",
"name_label": "Nom",
"name_placeholder": "Votre nom complet",
"email_label": "Courriel",
"email_placeholder": "vous@exemple.com",
"choose_option": "Choisissez une option",
"confirm_booking": "Confirmer la réservation",
"confirming": "Confirmation…",
"booking_confirmed": "Réservation confirmée",
"confirmation_email_sent": "Un courriel de confirmation vous a été envoyé.",
"required_fields_error": "Veuillez remplir tous les champs obligatoires.",
"slot_taken_error": "Ce créneau vient d'être réservé. Veuillez en choisir un autre.",
"booking_failed_error": "La réservation a échoué. Veuillez réessayer.",
"network_error": "Erreur de connexion. Vérifiez votre connexion internet.",
"loading": "Chargement…",
"no_available_times": "Aucun créneau disponible le %s.",
"no_available_times_host": "%[1]s n'a aucun créneau disponible le %[2]s.",
"slot_taken": "déjà réservé",
"all_times_taken": "Tous les horaires de cette journée sont réservés.",
"min_notice_hint": "Les réservations doivent être effectuées au moins %s à l'avance.",
"could_not_load_times": "Impossible de charger les créneaux. Veuillez réessayer.",
"times_shown_in": "Horaires affichés en ",
"payment_received": "Paiement reçu",
"payment_confirmed_sub": "Votre réservation est confirmée; un courriel de confirmation va vous parvenir.",
"assistant_error": "Désolé, une erreur est survenue. Veuillez utiliser le calendrier ci-dessous.",
"assistant_disclosure": "Vous discutez avec un assistant automatisé, pas avec une personne.",
"other": "autre",
"others": "autres",
"list_separator": ", ",
"list_conjunction": " et ",
"language_switcher_aria": "Langue",
"cookie_consent_aria": "Consentement aux cookies",
"cookie_text": "Nous utilisons des cookies analytiques pour comprendre l'utilisation de cette page de réservation; ils ne sont activés qu'avec votre accord.",
"privacy_policy": "Politique de confidentialité",
"decline": "Refuser",
"accept": "Accepter",
"privacy": "Confidentialité",
"terms": "Conditions",
"cookie_settings": "Paramètres des cookies",
"prev_month_aria": "Mois précédent",
"next_month_aria": "Mois suivant",
"demo_public_label": "Démo publique",
"demo_public_text": "les renseignements saisis ici sont visibles par tous et sont réinitialisés automatiquement. N'y saisissez aucun renseignement personnel.",
"demo_view_source": "Voir le code source",
"powered_by": "Propulsé par",
"duration_min": "%d min",
"duration_hour_one": "1 heure",
"duration_hours": "%d heures",
"duration_hr_min": "%d h %d min",
"location_video_meeting": "Visioconférence",
"location_phone": "Appel téléphonique",
"location_in_person": "En personne",
"location_video_call": "Visioconférence",
"manage_booking_title": "Gérer la réservation",
"link_expired_title": "Lien expiré ou invalide",
"link_expired_text": "Ce lien de gestion a expiré ou n'est plus valide. Consultez votre courriel de confirmation pour un lien valide, ou contactez l'organisateur pour en obtenir un nouveau.",
"booking_already_cancelled_title": "Réservation déjà annulée",
"booking_already_cancelled_text": "Cette réservation a été annulée. Pour réserver un nouveau créneau, rendez-vous sur la page de l'événement.",
"scheduled_for": "Prévu le",
"reschedule": "Reporter",
"cancel_booking": "Annuler la réservation",
"select_a_date": "Sélectionnez une date",
"pick_day_hint": "Choisissez un jour dans le calendrier.",
"confirm_reschedule_question": "Confirmer le report?",
"was_label": "Avant",
"now_label": "Maintenant",
"confirm_reschedule": "Confirmer le report",
"rescheduling": "Report en cours…",
"cancel_this_booking_question": "Annuler cette réservation?",
"cancel_booking_notice": "Cette action est irréversible. Un courriel d'annulation vous sera envoyé, ainsi qu'à l'organisateur.",
"reason_optional_label": "Motif (facultatif)",
"reason_placeholder": "ex. conflit d'horaire",
"yes_cancel_booking": "Oui, annuler la réservation",
"cancelling": "Annulation…",
"booking_rescheduled_title": "Réservation reportée",
"booking_rescheduled_prefix": "Votre réservation a été déplacée au ",
"booking_cancelled_title": "Réservation annulée",
"booking_cancelled_text": "Votre réservation a été annulée.",
"slot_no_longer_available_error": "Ce créneau n'est plus disponible. Veuillez en choisir un autre.",
"reschedule_failed_error": "Le report a échoué. Veuillez réessayer.",
"already_cancelled_error": "Cette réservation est déjà annulée.",
"cancellation_failed_error": "L'annulation a échoué. Veuillez réessayer.",
"dow_short_sun": "dim.",
"dow_short_mon": "lun.",
"dow_short_tue": "mar.",
"dow_short_wed": "mer.",
"dow_short_thu": "jeu.",
"dow_short_fri": "ven.",
"dow_short_sat": "sam.",
"month_short_jan": "janv.",
"month_short_feb": "févr.",
"month_short_mar": "mars",
"month_short_apr": "avr.",
"month_short_may": "mai",
"month_short_jun": "juin",
"month_short_jul": "juill.",
"month_short_aug": "août",
"month_short_sep": "sept.",
"month_short_oct": "oct.",
"month_short_nov": "nov.",
"month_short_dec": "déc.",
"clock_format": "24h",
"date_format": "%[1]s %[2]d %[3]s %[4]d",
"email_greeting": "Bonjour %s,",
"email_confirmed_lead": "Votre réservation est confirmée.",
"email_cancelled_lead": "Votre réservation a été annulée.",
"email_rescheduled_lead": "Votre réservation a été reportée.",
"email_reminder_lead": "Ceci est un rappel : votre rendez-vous approche.",
"email_label_event": "Événement",
"email_label_with": "Avec",
"email_label_start": "Début",
"email_label_end": "Fin",
"email_label_when": "Quand",
"email_label_location": "Lieu",
"email_label_reason": "Motif",
"email_label_was": "Avant",
"email_label_now": "Maintenant",
"email_add_to_calendar": "Ajoutez à votre calendrier :",
"email_add_to_calendar_updated": "Ajoutez à votre calendrier (horaire mis à jour) :",
"email_calendar_google": "Google Calendar",
"email_calendar_outlook": "Outlook",
"email_booking_reference": "Référence de la réservation : %s",
"email_manage_link_intro": "Pour reporter ou annuler, rendez-vous sur :",
"email_manage_link_intro_reschedule": "Pour reporter ou annuler à nouveau, rendez-vous sur :",
"email_cancel_link_intro": "Pour annuler, rendez-vous sur :",
"email_rebook_intro": "Pour réserver à nouveau, rendez-vous sur :",
"email_manage_button": "Gérer la réservation",
"email_rebook_button": "Réserver à nouveau",
"email_subject_confirmed": "Réservation confirmée : %s",
"email_subject_cancelled": "Réservation annulée : %s",
"email_subject_rescheduled": "Réservation reportée : %s",
"email_subject_reminder": "Rappel : %s approche",
"email_booking_with": "Réservation avec %s",
"email_manage_this_booking": "Gérer cette réservation :",
"calendar_event_summary": "%s avec %s",
"calendar_event_booking_id": "Référence de la réservation : %s",
"err_booking_limit": "Vous avez déjà atteint le nombre maximum de réservations à venir pour cet événement. Annulez une réservation existante ou attendez qu'elle soit passée avant d'en créer une nouvelle.",
"err_email_throttled": "Trop de réservations récentes depuis cette adresse courriel. Veuillez réessayer plus tard.",
"err_required_field": "le champ obligatoire %q est manquant",
"err_required_checkbox": "%q doit être coché",
"err_invalid_option": "option non valide pour %[1]q : %[2]q n'est pas un choix autorisé",
"assistant_unavailable": "L'assistant de réservation n'est pas disponible pour le moment. Choisissez un créneau dans le calendrier ci-dessous.",
"assistant_trouble": "Désolé, nous rencontrons un problème. Choisissez un créneau dans le calendrier ci-dessous.",
"assistant_iteration_cap": "Simplifions les choses : choisissez un créneau dans le calendrier ci-dessous.",
"assistant_status_slots": "Vérification des disponibilités…",
"assistant_status_book": "Réservation…",
"assistant_status_working": "En cours…"
}
Loading