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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
`-copy-2`, `-copy-3`, …) slug, and keeps `price_cents`/`currency` verbatim: zeroing a
copied price is how a paid meeting quietly starts selling for nothing. Bookings are not
copied.
- **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.

- **Empty days and minimum-notice gaps now explain themselves** on all three booking
surfaces (booking page, manage/reschedule page, embed widget). Closes
[#20](https://github.com/Calnode/calnode/issues/20).
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
13 changes: 12 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,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