Counts in the UI read "member(s)" / "card(s)" — four strings, both locales #144

Closed
opened 2026-08-21 15:51:20 +00:00 by claude-bot · 2 comments
Collaborator

Four translated strings put the plural in brackets rather than picking a word, so a count of one reads as machine output:

  • admin.boards.access.restricted — "Shared with {0} member(s)" / "Für {0} Mitglied(er) freigegeben". This one is now the most visible of the four: it is the access chip on every row of /admin/boards.
  • admin.boards.delete.cards — "Can't delete: this board still holds {0} card(s). Delete them first."
  • card.comments — "{0} comment(s)"
  • cal.day.count — "{0} card(s) due"

All four are in src/Ponente.Kanban/Services/LocaleService.cs, twice each (en and de). It is a consistent house convention rather than four separate slips, which is why the visual-polish pass for #141 left it alone — changing one would have made it the odd one out.

The smallest fix that works for both locales this app ships is a singular/plural key pair ( and ….one) chosen in C# on count == 1; English and German share the same rule for all four of these. LocaleTests would want a matching check that a .one key exists wherever the plural one does, so a third locale cannot quietly inherit English's rule.


Filed by Claude while working on #141 (run).

Four translated strings put the plural in brackets rather than picking a word, so a count of one reads as machine output: - `admin.boards.access.restricted` — "Shared with {0} member(s)" / "Für {0} Mitglied(er) freigegeben". This one is now the most visible of the four: it is the access chip on every row of `/admin/boards`. - `admin.boards.delete.cards` — "Can't delete: this board still holds {0} card(s). Delete them first." - `card.comments` — "{0} comment(s)" - `cal.day.count` — "{0} card(s) due" All four are in `src/Ponente.Kanban/Services/LocaleService.cs`, twice each (en and de). It is a consistent house convention rather than four separate slips, which is why the visual-polish pass for #141 left it alone — changing one would have made it the odd one out. The smallest fix that works for both locales this app ships is a singular/plural key pair (`…` and `….one`) chosen in C# on `count == 1`; English and German share the same rule for all four of these. `LocaleTests` would want a matching check that a `.one` key exists wherever the plural one does, so a third locale cannot quietly inherit English's rule. --- _Filed by Claude while working on #141 ([run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/369))._
Author
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/371); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-144/run-371.md) — updating every ~5 minutes while the task runs.
Author
Collaborator

Pushed claude/issue-144.

What changed

All four counted strings are now a pair of keys — the plural under the key it always had, the singular under <key>.one — picked in C# by a new LocaleService.TCount(key, count) on count == 1, in both locales:

key plural .one
admin.boards.access.restricted Shared with {0} members / Für {0} Mitglieder freigegeben Shared with {0} member / Für {0} Mitglied freigegeben
admin.boards.delete.cards … holds {0} cards. Delete them first. … holds {0} card. Delete it first. (de: liegenliegt)
card.comments {0} comments / {0} Kommentare {0} comment / {0} Kommentar
cal.day.count {0} cards due / {0} Karten fällig {0} card due / {0} Karte fällig

Two forms rather than a plural-rules library, because English and German agree on the rule for all four; the XML doc says where that stops being true (a language with more forms is the point to reach for real plural rules, not a third suffix). The five call sites — the board card's comment badge, the comment list header, the access chip, the delete error, the calendar day tooltip — now go through TCount.

The tests the issue asked for, plus the other direction

  • Every_counted_string_is_a_complete_pair_in_every_locale — for every locale, having the plural and having the .one must be the same answer, so a third locale can't translate one half and quietly inherit English's rule for the other.
  • Every_counted_string_the_app_asks_for_exists — scans src/ for TCount("…") call sites and requires a pair for each. The dictionary can't say which keys are counted; only the call sites can.
  • A_count_picks_the_word_that_fits_it — 0/1/4 in both locales, through a real LocaleService.
  • No_copy_puts_its_plural_in_brackets — the house convention flipped: nothing goes back to (s) / (er).
  • access.spec.js gets a check that the chip on a one-member board contains 1 and no bracket — asserted that way rather than on the sentence, because that file deliberately never matches on translated copy.

Also updated: docs/i18n.md (a paragraph on the pair and why two forms is the ceiling), the i18n convention line in CLAUDE.md, and the unreleased changelog — folded into the existing tidy-up bullet rather than added as a ninth line, since that block is already at the seven-line cap ChangelogTests enforces.

Verified

dotnet build clean, dotnet test 389/389 passing. I also confirmed the parity test actually fires by deleting one German .one and watching it fail. Then I ran the app for real (local Postgres + dotnet run, three boards, one restricted to one member and one to two) and drove it with Playwright in both locales — before is main at HEAD, after is this branch, same database:

The /admin/boards access chip before and after, in English and German: "Shared with 1 member(s)" becomes "Shared with 1 member", "Für 1 Mitglied(er) freigegeben" becomes "Für 1 Mitglied freigegeben"

The Playwright suite itself I did not run — it needs the compose stack for the PDS container and there's no podman/docker in this job container. The one assertion I added is a one-liner against the chip I checked by hand above.


⏱ 14m 38s · 92 turns · tokens: 110.9k in / 38.8k out (+7338.1k cache-read)
📜 Full session transcript
→ Opened PR: #145

Pushed `claude/issue-144`. ## What changed All four counted strings are now a pair of keys — the plural under the key it always had, the singular under `<key>.one` — picked in C# by a new `LocaleService.TCount(key, count)` on `count == 1`, in both locales: | key | plural | `.one` | |---|---|---| | `admin.boards.access.restricted` | Shared with {0} members / Für {0} Mitglieder freigegeben | Shared with {0} member / Für {0} Mitglied freigegeben | | `admin.boards.delete.cards` | … holds {0} cards. Delete them first. | … holds {0} card. Delete it first. (de: *liegen* → *liegt*) | | `card.comments` | {0} comments / {0} Kommentare | {0} comment / {0} Kommentar | | `cal.day.count` | {0} cards due / {0} Karten fällig | {0} card due / {0} Karte fällig | Two forms rather than a plural-rules library, because English and German agree on the rule for all four; the XML doc says where that stops being true (a language with more forms is the point to reach for real plural rules, not a third suffix). The five call sites — the board card's comment badge, the comment list header, the access chip, the delete error, the calendar day tooltip — now go through `TCount`. ## The tests the issue asked for, plus the other direction - **`Every_counted_string_is_a_complete_pair_in_every_locale`** — for every locale, having the plural and having the `.one` must be the same answer, so a third locale can't translate one half and quietly inherit English's rule for the other. - **`Every_counted_string_the_app_asks_for_exists`** — scans `src/` for `TCount("…")` call sites and requires a pair for each. The dictionary can't say which keys are counted; only the call sites can. - **`A_count_picks_the_word_that_fits_it`** — 0/1/4 in both locales, through a real `LocaleService`. - **`No_copy_puts_its_plural_in_brackets`** — the house convention flipped: nothing goes back to `(s)` / `(er)`. - `access.spec.js` gets a check that the chip on a one-member board contains `1` and no bracket — asserted that way rather than on the sentence, because that file deliberately never matches on translated copy. Also updated: `docs/i18n.md` (a paragraph on the pair and why two forms is the ceiling), the i18n convention line in `CLAUDE.md`, and the unreleased changelog — folded into the existing tidy-up bullet rather than added as a ninth line, since that block is already at the seven-line cap `ChangelogTests` enforces. ## Verified `dotnet build` clean, `dotnet test` 389/389 passing. I also confirmed the parity test actually fires by deleting one German `.one` and watching it fail. Then I ran the app for real (local Postgres + `dotnet run`, three boards, one restricted to one member and one to two) and drove it with Playwright in both locales — before is `main` at HEAD, after is this branch, same database: ![The /admin/boards access chip before and after, in English and German: "Shared with 1 member(s)" becomes "Shared with 1 member", "Für 1 Mitglied(er) freigegeben" becomes "Für 1 Mitglied freigegeben"](https://git.grandiras.net/attachments/a9c17bf4-4375-4b62-8eae-e57017c11e21) The Playwright suite itself I did not run — it needs the compose stack for the PDS container and there's no podman/docker in this job container. The one assertion I added is a one-liner against the chip I checked by hand above. --- ⏱ 14m 38s · 92 turns · tokens: 110.9k in / 38.8k out (+7338.1k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-144/run-371.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/145
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#144
No description provided.