Skip to content

feat(calendar): translate task title and description server-side (#1384) - #1405

Merged
renemadsen merged 6 commits into
stablefrom
fix/1384-server-side-translation
Oct 4, 2026
Merged

renemadsen merged 6 commits into
stablefrom
fix/1384-server-side-translation

Conversation

@renemadsen

@renemadsen renemadsen commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

eFormApi.BasePn 10.0.37 is on nuget.org (microting/eFormApi.BasePn#974). Merge after microting/eform-angular-frontend#8110, which makes the host register the BasePn translator.

Summary

v2 of #1324. Task titles and descriptions are now translated server-side, so every entry point fills a recipient's missing language, not only the calendar dialog.

Where it runs

  • Task wizard CreateTask/UpdateTask: the translation is filled into the request before anything is saved. This covers the calendar, the task-list batch actions and the Task Wizard page.
  • CalendarAssignmentReconciliationService.ReconcileEventAsync: fills the database translations once the effective recipients are known. This covers team-only tasks, whose team links are written after the wizard saves, and members who join a team later.
  • UpdateDeviceUser language change: fills only that worker's events, both explicit ones and team events on a property the worker is linked to. It deploys nothing. This step and the tag-delta reconcile now run as the last step of a successful save, after SiteUpdate and the login writes. A failure is logged and never fails the save. It is not retried by that worker's next save; the affected events catch up on their next reconcile (a calendar save of the event or a later tag change), the same gap as before, when a throwing reconcile failed the save after the tags were written.

Rules (same as v1)

  • Only an empty title or description is filled, from the Danish text. Nothing typed by hand or translated earlier is overwritten, and a changed Danish text is not re-translated.
  • Targets are the active non-Danish languages of the recipients. Danish is found by its language code, never by id, and a da language is never a target.

Translator

  • It is the host's eFormApi.BasePn ITranslationService, resolved optionally. With no service, no API key, or a host BasePn without the type, the task is saved in Danish.
  • In that case the save reports TaskSavedWithoutTranslation in the wizard and calendar success messages and in the task-list batch summary.
  • Per filler instance, each (text, target) is translated once. After the translator throws, no further calls are made.

No gRPC path reaches the wizard. adhoc.proto creates single-title ad-hoc tasks, and events.proto/calendar.proto only read. So there is no gRPC-created task to translate.

Tests

New TaskServerSideTranslationTests run the real wizard, calendar, task list, reconcile and assignment-worker helper with a fake BasePn ITranslationService that returns [en-US] <text>:

  • Calendar create for an English worker stores English in both AreaRuleTranslation and PlanningNameTranslation, and never asks for target da.
  • A team-only task with an English member gets English.
  • A task-list batch AddWorker that adds an English worker fills English.
  • A manual English title is kept; only the empty description is translated.
  • An update that leaves out a stored English text keeps it.
  • With no translator, with an unconfigured one, with one that answers unsuccessfully, and with one that throws: the task saves in Danish only and the notice is present, including in the batch summary.
  • An English worker joining the team: ReconcileEventsForWorkerTagsAsync fills English.
  • A language change in UpdateDeviceUser fills only that worker's events, deploys nothing, and calls no reconcile.
  • A reconcile that throws during UpdateDeviceUser does not fail the save.

Not verified

  • Built locally against a scratch-packed BasePn 10.0.37; tests run in CI only.
  • The guard for an older host without the BasePn type was reasoned through, not exercised.
  • Not tried in a browser or on a device.

Refs #1384

🤖 Generated with Claude Code

renemadsen and others added 3 commits October 3, 2026 11:22
v2 of #1324. Every entry point now fills an assignee's missing language,
not only the calendar dialog: the task wizard's CreateTask/UpdateTask (the
calendar, the task-list batch actions, the Task Wizard page) and the
reconcile that expands teams (team links written after the wizard, members
joining later).

Rules as in v1: only an empty title or description is filled, from the
Danish text; nothing typed or translated earlier is overwritten. Targets
are the active non-Danish languages of the recipients; Danish is found by
its code, and a 'da' language is never a target.

The translator is the host's eFormApi.BasePn ITranslationService, resolved
optionally: no service, no API key, or a BasePn without the type leaves the
task in Danish, and the save reports TaskSavedWithoutTranslation (wizard,
calendar and task-list batch summary). Per filler instance a translation is
asked for once per (text, target), and a throwing translator is not called
again.

A device user's language change fills that worker's events (explicit, or
via a team on a linked property) without deploying anything; the tag
reconcile and the translation run as the last step of a successful save,
after SiteUpdate and the login writes, and never fail it.

No gRPC path reaches the wizard: adhoc.proto creates single-title ad-hoc
tasks, events.proto and calendar.proto only read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow (#1384)

The host turns a timeout into an unsuccessful answer, so tripping only on
an exception let a down translator cost one timeout per (text, target).
The filler now also stops after two unsuccessful answers in a row; a
success resets the count, so one unsupported language does not stop the
others. Tests pin both: two languages with a failing translator make two
calls, and a failing title between successful descriptions does not stop
the filler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@renemadsen
renemadsen marked this pull request as ready for review October 4, 2026 09:06
Copilot AI balanced review requested due to automatic review settings October 4, 2026 09:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 2 High severity · 5 Medium severity · 1 Low severity

Open (8)
What changed in this PR

Adds server-side task title/description translation so all task creation/update entry points populate recipients’ missing languages, and surfaces a consistent “saved without translation” notice when translation is unavailable.

Changes:

  • Introduces ITaskTranslationFiller / TaskTranslationFiller plus TaskTranslationNotice to fill missing translations and append/recognize the “saved without translation” notice.
  • Integrates translation filling into task wizard save paths, reconciliation, calendar create/update, and device-user language change follow-up.
  • Adds integration tests covering wizard/calendar/task-list/reconcile/device-user scenarios and bumps BasePn dependency for host translation service.
File Description
BackendConfiguration.Pn/​Services/​TaskTranslation/​TaskTranslationNotice.cs Adds helper to append and detect “saved without translation” notice.
BackendConfiguration.Pn/​Services/​TaskTranslation/​TaskTranslationFiller.cs Implements server-side translation fill logic with caching/failure cutoff and host-service resolution.
BackendConfiguration.Pn/​Services/​TaskTranslation/​ITaskTranslationFiller.cs Defines translation filler contracts for request-time and event-time fills.
BackendConfiguration.Pn/​Services/​CalendarAssignmentReconciliation/​ICalendarAssignmentReconciliationService.cs Changes reconcile API to return whether translations were incomplete.
BackendConfiguration.Pn/​Services/​CalendarAssignmentReconciliation/​CalendarAssignmentReconciliationService.cs Calls translation filler during reconcile and propagates incomplete flag.
BackendConfiguration.Pn/​Services/​BackendConfigurationTaskWizardService/​BackendConfigurationTaskWizardService.cs Fills translations before persisting and appends notice to success messages.
BackendConfiguration.Pn/​Services/​BackendConfigurationTaskListService/​BackendConfigurationTaskListService.cs Preserves the notice in batch summaries even when per-task messages are aggregated.
BackendConfiguration.Pn/​Services/​BackendConfigurationCalendarService/​BackendConfigurationCalendarService.cs Propagates translations-incomplete notice through calendar create/update results.
BackendConfiguration.Pn/​Services/​BackendConfigurationAssignmentWorkerService/​BackendConfigurationAssignmentWorkerService.cs Passes translation filler into device-user update flow (language change fill).
BackendConfiguration.Pn/​Infrastructure/​Helpers/​BackendConfigurationAssignmentWorkerServiceHelper.cs Adds calendar follow-up logic for tag changes and language changes + translation fill.
BackendConfiguration.Pn/​Infrastructure/​Models/​TaskWizard/​TaskWizardCreateModel.cs Adds TranslationsIncomplete flag (server-only) to propagate notice.
BackendConfiguration.Pn/​Resources/​localization.json Adds TaskSavedWithoutTranslation localized strings.
BackendConfiguration.Pn/​EformBackendConfigurationPlugin.cs Registers the translation filler in DI.
BackendConfiguration.Pn/​BackendConfiguration.Pn.csproj Bumps Microting.eFormApi.BasePn to 10.0.37.
BackendConfiguration.Pn.Integration.Test/​TaskServerSideTranslationTests.cs Adds integration tests covering server-side translation behavior and failure modes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

using CalendarOccurrenceRetraction;
using CalendarPastSeriesBackfill;
using WorkerTagMembership;
using TaskTranslation;
using Microting.EformBackendConfigurationBase.Infrastructure.Data.Entities;
using Microting.ItemsPlanningBase.Infrastructure.Data;
using Microting.ItemsPlanningBase.Infrastructure.Data.Entities;
using TaskTranslation;
"pt-BR": "A tradução não está disponível, então o título e a descrição foram salvos apenas em dinamarquês.",
"pt-PT": "A tradução não está disponível, pelo que o título e a descrição foram guardados apenas em dinamarquês.",
"fi-FI": "Käännös ei ole käytettävissä, joten otsikko ja kuvaus tallennettiin vain tanskaksi.",
"et-ET": "Tõlge pole saadaval, seega salvestati pealkiri ja kirjeldus ainult taani keeles.",
Comment on lines +3026 to +3031
"et-ET": "Tõlge pole saadaval, seega salvestati pealkiri ja kirjeldus ainult taani keeles.",
"lv-LV": "Tulkojums nav pieejams, tāpēc nosaukums un apraksts saglabāts tikai dāņu valodā.",
"lt-LT": "Vertimas nepasiekiamas, todėl pavadinimas ir aprašymas išsaugoti tik danų kalba.",
"ro-RO": "Traducerea nu este disponibilă, așa că titlul și descrierea au fost salvate doar în daneză.",
"hr-HR": "Prijevod nije dostupan pa su naslov i opis spremljeni samo na danskom.",
"sl-SL": "Prevod ni na voljo, zato sta naslov in opis shranjena samo v danščini.",
ILogger<CalendarAssignmentReconciliationService> logger)
ILogger<CalendarAssignmentReconciliationService> logger,
// #1384 — optional so the fixtures that build this by hand keep working.
ITaskTranslationFiller taskTranslationFiller = null)
Comment on lines +89 to +105
return await TranslateMissingAsync(danish.Name, danish.Description, targets,
languageId =>
{
// A language the request carries is saved as sent; one it leaves out
// keeps its stored text, which the upsert never touches.
var entry = translates.FirstOrDefault(t => t.LanguageId == languageId);
if (entry != null)
{
return (entry.Name, entry.Description);
}

stored.TryGetValue(languageId, out var row);
return (row?.Name, row?.Description);
},
(languageId, name, description) =>
{
var entry = translates.FirstOrDefault(t => t.LanguageId == languageId);
translationsIncomplete ? $"{message} {localizationService.GetString(Key)}" : message;

public static bool IsIn(IBackendConfigurationLocalizationService localizationService, string? message) =>
message != null && localizationService.GetString(Key) is { Length: > 0 } notice && message.Contains(notice);
var failed = results.Where(r => !r.Ok).ToList();
// #1384 — a successful task's message is otherwise dropped; keep its notice
// that some language was saved in Danish only.
var translationsIncomplete = results.Any(r => r.Ok && TaskTranslationNotice.IsIn(localizationService, r.Error));
Both device-user tests created "Jane Doe" in the same fixture database, so
the second CreateDeviceUser was refused with TheEmployeeAlreadyExist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 4, 2026 09:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Failed worker-update reconciliation is not actually retried on subsequent saves, potentially leaving translations and assignments stale.

Review effort: Balanced
Findings: 2 High severity · 5 Medium severity · 1 Low severity

Open (8)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Persist failed calendar reconciliation for retry

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Infrastructure/​Helpers/​BackendConfigurationAssignmentWorkerServiceHelper.cs:1092

The claimed retry cannot happen after this call fails. The site language and tag rows have already been persisted, so the next identical save computes languageChanged == false and an empty changedTagIds; SyncCalendarAfterDeviceUserUpdateAsync then performs neither repair. A transient failure can therefore leave missing translations or stale team deployments indefinitely. Persist pending reconciliation work (including removed tag IDs) before swallowing the exception, or otherwise make subsequent saves reconcile recoverable current state.

Low severity Make partial translation failure notice field-neutral

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Resources/​localization.json:3014

This notice is also emitted for partial failures: one field may already have a manual translation, or one translator call may succeed while the other fails. In those cases it is incorrect to tell the user that both title and description were saved only in Danish. Please make every localized value field-neutral, e.g. state that some requested translations could not be saved.

…d by the next save (#1384)

The comment and log line claimed the next save retries it; it cannot, as
the tags and language are already stored and that save sees no change.
The events catch up on their next reconcile. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 4, 2026 10:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Reactivation can omit device-label translations, and synchronous unbounded translation work risks slow worker saves.

Review effort: Balanced
Findings: 2 High severity · 5 Medium severity · 1 Low severity

Open (8)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Unbounded translation calls block the UpdateDeviceUser request

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Infrastructure/​Helpers/​BackendConfigurationAssignmentWorkerServiceHelper.cs:733

This loop performs up to two sequential remote translation calls for every active event during UpdateDeviceUser. The event set is unbounded, and successful calls never trip the failure cap, so a worker with many uniquely titled events can keep the save request open for hundreds of network round trips. Queue this non-critical post-save work or process it through a bounded/batched translation job instead of awaiting the entire history in the HTTP request.

Medium severity Reactivation omits newly added planning name translations

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Services/​BackendConfigurationTaskWizardService/​BackendConfigurationTaskWizardService.cs:819

When this adds a new target-language entry while an inactive task is reactivated, the later false -> true status branch only loops over existing planning.NameTranslations (around line 1136); it never inserts the newly added language. The AreaRuleTranslation is therefore saved, but PlanningNameTranslation—which supplies the device label—remains missing on the direct Task Wizard update path. Upsert names from updateModel.Translates in the reactivation branch as the active-to-active branch does.

Low severity Partial translation failures produce an inaccurate notice

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Resources/​localization.json:3014

This notice is also emitted for partial failures—for example, when a manual title is retained and only description translation fails, or one language fails while another succeeds. Saying both fields were saved only in Danish is then incorrect. Use wording such as “Some missing translations could not be generated; untranslated fields remain in Danish” consistently across the localized variants.

…ded language (#1384)

The wizard's inactive -> active branch only updated existing
PlanningNameTranslation rows from the area rule, so a language added in
the same save (typed, or filled by the server-side translation) got its
AreaRuleTranslation but no planning name - the one the device label reads -
before Pair deployed. Create the missing rows there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 4, 2026 10:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Shifted language IDs, team-only scoped updates, and stale translation-status aggregation can produce missing or inaccurate translation outcomes.

Review effort: Balanced
Findings: 2 High severity · 5 Medium severity · 1 Low severity

Open (8)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Stale wizard failure reported after successful active-task reconciliation

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Services/​BackendConfigurationCalendarService/​BackendConfigurationCalendarService.cs:1863

For an active task, |= keeps the wizard's failure flag even when this final reconcile successfully fills every missing explicit/team translation. Production gives the wizard and reconciler different transient filler instances, so a transient first failure can recover here while the response still falsely says the task was saved in Danish only. Recompute the final completeness after reconciliation (while retaining the wizard result for inactive tasks, which reconciliation skips).

This issue also appears on line 2425 of the same file.

Medium severity Team-only recipients skipped in thisAndFollowing path

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Services/​BackendConfigurationCalendarService/​BackendConfigurationCalendarService.cs:2964

The thisAndFollowing path returns after running only the wizard filler, which sees explicit Sites but not team members. Because this branch intentionally never calls the reconciler, a team-only task whose translation previously failed still remains untranslated (and reports no notice) after this calendar save, despite the stated catch-up-on-next-calendar-save behavior. Resolve the effective team recipients and fill them before returning, without triggering deployment if that remains out of scope for this edit mode.

Medium severity Fallback language ID skips source translation on shifted SDK IDs

eFormAPI/​Plugins/​BackendConfiguration.Pn/​BackendConfiguration.Pn/​Services/​TaskTranslation/​TaskTranslationFiller.cs:81

When the frontend falls back to language id 1, a tenant whose SDK id 1 belongs to another language never reaches the source translation: RemapCommonTranslationLanguageIdsAsync deliberately leaves every existing id unchanged, so this lookup finds no Danish row and returns true. The task is then saved without recipient translations and without the promised notice. This is especially relevant on the shifted-id installations documented in AreaRuleLanguageHelper.cs:104-115; normalize the fallback source by the SDK language code before treating the fill as complete.

@renemadsen
renemadsen merged commit 963c9a6 into stable Oct 4, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants