From 408f12151f5483c7c3707654bc19a95c1e1ab253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 11:15:59 +0200 Subject: [PATCH 01/38] docs: design for an in-page help system on the planning page The planning page is not admin-gated: any user holding time_planning_plugin_access can edit almost everything on it, including the 2001-line day-cell editor, with nothing on the page explaining flex, pauses, netto hours, or what saving triggers. The 67 existing matTooltips are icon labels, not explanations, and no help affordance of any kind exists in either repo. Design: one help registry defines ~36 explainable things; four surfaces read from it (info icon, side panel, two tours, inline hints), so an answer written once appears everywhere. Prose lives in its own per-locale files so the 25 existing i18n files stay untouched, with per-entry fallback to English. No backend, no new dependency, no change to eform-angular-frontend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../2026-09-04-planning-help-system-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-planning-help-system-design.md diff --git a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md new file mode 100644 index 00000000..bf568728 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md @@ -0,0 +1,243 @@ +# In-page help system for the planning page + +**Date:** 2026-09-04 +**Status:** Design approved, implementation plan not yet written +**Scope:** `plugins/time-planning-pn/planning` only + +## Problem + +A team lead or planner opens the planning page and can edit almost everything on it, +but nothing on the page explains the rules they are editing against. Flex, pauses, +netto hours, the difference between planned and actual times, what saving actually +triggers — none of it is written down anywhere the user can reach. Customer support +answers the same questions repeatedly. + +The page carries 67 `matTooltip`s, but they are icon labels ("Download Excel", +"Reload table"), not explanations. There is no help affordance of any kind — no `?` +button, no popover, no tour — anywhere in either repo. + +## Audience + +Primary: the **team lead / planner** — a non-admin who plans hours for a group of +workers, can edit rows, and does not know the rules. + +Secondary: **customer support and onboarding** — the goal is that an answer written +once appears everywhere a user might look for it. + +## What a non-admin can actually do + +The planning page is **not admin-gated**. Its route guard requires only the +`time_planning_plugin_access` claim (`time-planning-pn.routing.ts:18-24`). Inside the +page, exactly two things are admin-only: + +| Control | Gate | +|---|---| +| Export to payroll button | `time-plannings-container.component.html:87`, and server-side via `PayrollExportController.cs:12` | +| Assigned-site dialog (click on worker name) | `time-plannings-table.component.ts:370-372`, **client-side only** — the endpoint it calls checks the `GetWorkingHours` claim, not the admin role | + +Everything else — filters, navigation, Excel download, reload, and the entire day-cell +editor — is available to any user who can load the page. That editor, +`WorkdayEntityDialogComponent`, is a 2001-line component with up to five shifts of +planned and actual times, per-field resets, GPS and snapshot viewers, plan hours, +netto override, paid-out flex, flag checkboxes and comments. It is where a planner is +lost, and it is therefore the centre of gravity for this work. + +## Approach + +One **help registry** defines every explainable thing on the page. Four surfaces read +from it. Support edits an answer once and it appears in all four. + +Rejected alternatives: + +- **Flat translate keys in the existing locale files.** No new machinery, but it + roughly doubles each of the 25 locale files, buries UI labels among prose, and makes + every wording tweak a 25-file diff. +- **Backend-served help content.** Editable without a release, but requires a new API, + table and migration in a `-base` repo, which base dev mode cannot touch. + +## Content model + +`time-planning-pn/help/planning-help.registry.ts` — structure only, no prose: + +```ts +export type HelpSection = + | 'toolbar' | 'grid' | 'dayCell' | 'shifts' | 'flex' | 'flags'; + +export interface HelpEntry { + id: HelpEntryId; // stable identifier, e.g. 'dayCell.actualPause' + section: HelpSection; + anchor?: string; // data-tp-help value; required if tourStep is set + tourStep?: number; // present = included in a tour; value = order within it + tour?: 'page' | 'dialog'; + adminOnly?: boolean; // filtered out when the current user is not an admin +} +``` + +Prose lives separately, in `help/i18n/enUS.ts` and `help/i18n/da.ts`: + +```ts +export const enUS: Record = { ... }; +``` + +`short` is one or two sentences and is what the ⓘ popover and the panel summary show. +`detail` is an optional further paragraph shown only in the panel. + +`HelpContentService` resolves prose against ngx-translate's `currentLang` and falls +back to English **per entry**, so a partially translated Danish file degrades +entry-by-entry rather than all-or-nothing. + +The existing 25 locale files under `time-planning-pn/i18n/` are not modified. + +### Locale coverage + +English and Danish are authored properly. The other 23 locales fall back to English. +Adding a locale later is a pure content commit — a new file under `help/i18n/`, no code +change. Machine-translating ~1750 strings up front was rejected as worse than honest +English. + +## The four surfaces + +| Surface | Component | Reads | +|---|---|---| +| ⓘ icon | `` | `short` | +| Side panel | `` | all entries, grouped by `section` | +| Tour | `HelpTourService` | entries with `tourStep`, positioned via `anchor` | +| Inline hint | `` | `short` | + +**ⓘ icon.** A small `mat-icon-button` whose `aria-label` comes from the entry. Opens a +CDK connected overlay using `cdkConnectedOverlayUsePopover="inline"`, which renders +into the browser top layer. This matters because roughly half the help lives inside a +`MatDialog`: a body-appended overlay would fight the dialog's own z-index and focus +trap. Dismissed on Escape, backdrop click, and scroll. + +**Side panel.** A right-side slide-in opened by a single `?` button placed in the +container toolbar after the last `div.line-vert` +(`time-plannings-container.component.html:78`), styled +`btn-secondary--icon-rounded-border` to match the five icon buttons already there. +Entries render grouped by section in registry order. Opening the panel from an ⓘ's +"More" link scrolls to that entry. + +**Tour.** `HelpTourService` walks entries with a `tourStep`, positioning the same CDK +overlay against `[data-tp-help=""]`. Runs once automatically per user +(`localStorage` key `tp.planning.tour.v1`) and is replayable from the panel. **A step +whose anchor is absent from the DOM is skipped, not treated as an error** — this is +required, because the worker select only renders when `availableSites.length > 1` and +the payroll button only renders for admins. + +**Inline hint.** Renders the pattern already used ~8 times in this plugin — a +`div.help-text` containing `mat-icon>info` and a span (see +`pay-day-rule-form.component.html:104-107`). Used where the page currently explains +nothing: + +- Fields disabled because the date is in the future +- The "Total planned hours cannot exceed 24" validation +- An empty grid when filters match no workers +- **A non-admin clicking a worker's name.** Today this silently does nothing — the + click handler is bound in both template branches and the admin check happens inside + the method body (`time-plannings-table.component.ts:370-372`). A hint explaining + that this opens worker settings for administrators replaces silent failure with an + answer. + +## Anchoring + +Anchors are `data-tp-help=""` **attributes added to templates**, never CSS +selectors. `mtx-grid` regenerates DOM and its class names are shared across columns, so +selector-based anchoring would be silently fragile. This adds roughly 35 attributes +across the container, table and workday-dialog templates. It is the only invasive part +of the change, and it is purely additive — attributes and ⓘ elements, with no logic +touched. + +**Two tours, not one.** A tour cannot span the page and a modal in a single run. The +`page` tour covers the toolbar and grid and ends by inviting the user to open a day. +The `dialog` tour is offered from inside `WorkdayEntityDialogComponent`. + +## Entry inventory + +Approximately 36 entries. + +### `toolbar` (9) + +`showResigned`, `navBackward`, `navForward`, `workerFilter`, `tagFilter`, `dateRange`, +`downloadExcel`, `payrollExport` (adminOnly), `reload`. + +### `grid` (8) + +`nameColumn`, `tagChips` (click-to-filter), `settingsStrip` (the pay-rule / +mobile-registration / over-midnight / auto-break / one-minute / extra-shifts status +badges), `dayCellAnatomy` (planned versus actual, and the icon legend), +`weeklyPlannedHours`, `messageIcons`, `sortName`, `openDay`. + +`weeklyPlannedHours` deserves care: the page renders **two different** `plannedHours` +elements — a weekly total and a per-day-cell value. The help text must name which one +it describes, or it will actively mislead. + +### `dayCell` (16) + +`versionHistory`, `plannedTimes`, `actualTimes`, `shiftCount`, `resetField`, +`resetPauseToRecorded`, `gps`, `snapshot`, `futureDisabled`, `planHours`, +`nettoOverride`, `paidOutFlex`, `flags`, `commentOffice`, `save`, +`oneMinuteIntervals` (the timepicker's `minutesGap` is 1 or 5 depending on the +worker's setting). + +### `flex` (3) + +`whatIsFlex`, `sumFlex`, `paidOutFlexRelation`. + +The flex entries must describe **what the user sees and controls**, not restate the +server's arithmetic. The `SumFlexEnd - PaiedOutFlex` calculation is duplicated in +roughly five places in the backend and `PaiedOutFlexInSeconds` is frequently +unpopulated, so help text asserting a precise formula risks contradicting what the +page actually displays for a given worker. + +## Admin-awareness + +Exactly one entry carries `adminOnly`: `toolbar.payrollExport`. The panel and both +tours filter it through `selectAuthIsAdmin$` (`auth.selector.ts:17-18`). + +`grid.nameColumn` is deliberately **not** `adminOnly`, even though the dialog it +describes is. A non-admin clicking a worker's name gets silence today; the whole point +of its entry is to tell that user what the column is and that opening worker settings +requires an administrator. Marking it `adminOnly` would hide the answer from exactly +the person asking the question. + +Every other entry is shown to all users, which matches how the page is actually gated. + +## Testing + +- **`HelpContentService` fallback** — an entry missing from `da.ts` resolves to the + English text; a present entry does not. +- **Registry integrity** — every entry with a `tourStep` also declares a `tour` and an + `anchor`; `tourStep` values are unique within each tour; every `helpId` referenced in + a template exists in the registry; every registry id has prose in `enUS`. This is the + test that prevents rot: it fails when someone typos an id, or deletes a control and + leaves its help entry behind. +- **Admin filtering** — a non-admin sees neither `adminOnly` entry in the panel, and + the page tour skips the payroll step. + +No end-to-end tests. Per the project's standing practice, changes are pushed and +verified in CI rather than run locally. + +## Out of scope + +- Any backend change, API, or migration +- Any new npm dependency — no tour library exists in either repo, and the tour is + roughly 200 lines over the CDK overlay that Angular Material 20.2.14 already provides +- Any change to `eform-angular-frontend` or `eform-shared`. The plugin repo contains + only `src/app/plugins/`, with no `src/app/common`, so the help system lives entirely + inside `time-planning-pn/` — which is also correct, since it then ships and versions + with the plugin +- Any change to the 25 existing locale files +- The admin-only settings pages (pay rule sets, break policies) — separate pages, + separate scope + +## Implementation note: repository state + +The host-app mirror at +`eform-angular-frontend/eform-client/src/app/plugins/modules/time-planning-pn/` is +**behind** this repository by 68 files, including the tag-chip click-to-filter feature, +the settings-strip status icons, and all 20 shared i18n files. Running +`devgetchanges.sh` would therefore copy stale host files over this repo and delete +those features. + +All work for this design happens **directly in this repository**. The host mirror is +not edited, and `devgetchanges.sh` is not run. From 0642f026d6e31f41899fdaaaba087e46983996a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 11:24:42 +0200 Subject: [PATCH 02/38] =?UTF-8?q?docs:=20add=20copy=20rules=20=E2=80=94=20?= =?UTF-8?q?help=20text=20never=20references=20admin=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Admin" means Microting, not a customer role, so help copy must not mention administrator capabilities, explain what someone with more access could do, or account for why a control did nothing. The worker-column entry was written the wrong way round: it explained that clicking opens worker settings for administrators. It now names only the four things the column displays — worker, weekly hours, tags, and the status icons for the rules applying to them. Adds a Copy rules section carrying this plus the two authoring rules already exercised elsewhere in the spec: name which value is meant when a label is reused, and describe what the screen shows rather than how the server computes it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../2026-09-04-planning-help-system-design.md | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md index bf568728..fcc266c9 100644 --- a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md +++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md @@ -132,11 +132,9 @@ nothing: - Fields disabled because the date is in the future - The "Total planned hours cannot exceed 24" validation - An empty grid when filters match no workers -- **A non-admin clicking a worker's name.** Today this silently does nothing — the - click handler is bound in both template branches and the admin check happens inside - the method body (`time-plannings-table.component.ts:370-372`). A hint explaining - that this opens worker settings for administrators replaces silent failure with an - answer. +- **The worker column**, which packs four unlabelled things into one cell — name, + agreed weekly hours, tags, and a strip of status icons for the rules that apply to + that worker. The hint names what is being shown. ## Anchoring @@ -194,14 +192,31 @@ page actually displays for a given worker. Exactly one entry carries `adminOnly`: `toolbar.payrollExport`. The panel and both tours filter it through `selectAuthIsAdmin$` (`auth.selector.ts:17-18`). -`grid.nameColumn` is deliberately **not** `adminOnly`, even though the dialog it -describes is. A non-admin clicking a worker's name gets silence today; the whole point -of its entry is to tell that user what the column is and that opening worker settings -requires an administrator. Marking it `adminOnly` would hide the answer from exactly -the person asking the question. +`grid.nameColumn` is not `adminOnly` — it describes what the column *displays*, which +every user sees. It says nothing about the dialog behind it. Every other entry is shown to all users, which matches how the page is actually gated. +## Copy rules + +**"Admin" means Microting, not a customer role.** Help copy therefore never mentions +administrator capabilities, never explains what someone with more access could do, and +never accounts for why a control did nothing. An entry describes what the reader sees +and what the reader can do — nothing else. + +This rules out a whole tempting category of text. The worker column is the clearest +case: clicking it opens a settings dialog for Microting staff and silently does nothing +for everyone else, and the entry must still confine itself to describing the four +things the column displays. "This opens worker settings for administrators" is exactly +the sentence not to write. + +Two further rules, both already exercised above: + +- Name which value is meant when a label is reused. `plannedHours` renders as both a + weekly total and a per-day-cell value. +- Describe what the screen shows, not how the server computes it — see the flex note + under the entry inventory. + ## Testing - **`HelpContentService` fallback** — an entry missing from `da.ts` resolves to the From d6b63cbef196bfcc57d925315c486d22b449f9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 11:31:04 +0200 Subject: [PATCH 03/38] docs: add help search and task entries to the planning help design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registry of control descriptions is not searchable in the way users actually ask. Nobody searches for a control named "vacation" — no such control exists — so "how do I register vacation for a worker" would return nothing. Adds a second entry kind. Control entries answer "what is this?" and are anchored on screen; task entries answer "how do I do X?", carry ordered steps and a related list pointing back at the controls they touch. Twelve tasks, taking the registry to ~48 entries. Search sits at the top of the panel: substring, case- and diacritic-insensitive so Danish folds, run over both the active locale and the English fallback, with tasks ranked above controls and the task list shown instead of an empty result. Per-locale keywords carry the synonyms — ferie, sygdom, afspadsering, barsel appear in no English string. Records the flag rule the three leave-related tasks exist to explain: day flags render as checkboxes but are mutually exclusive, and ticking one rewrites netto hours — 0 for DayOff and VacationDayOff, the planned hours for every other flag. Adjacent checkboxes, opposite results. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../2026-09-04-planning-help-system-design.md | 96 +++++++++++++++++-- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md index fcc266c9..f6cbcf33 100644 --- a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md +++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md @@ -61,27 +61,50 @@ Rejected alternatives: ```ts export type HelpSection = - | 'toolbar' | 'grid' | 'dayCell' | 'shifts' | 'flex' | 'flags'; + | 'task' | 'toolbar' | 'grid' | 'dayCell' | 'shifts' | 'flex' | 'flags'; + +export type HelpKind = 'control' | 'task'; export interface HelpEntry { id: HelpEntryId; // stable identifier, e.g. 'dayCell.actualPause' + kind: HelpKind; section: HelpSection; anchor?: string; // data-tp-help value; required if tourStep is set tourStep?: number; // present = included in a tour; value = order within it tour?: 'page' | 'dialog'; adminOnly?: boolean; // filtered out when the current user is not an admin + related?: HelpEntryId[]; // tasks only: the controls the task touches } ``` Prose lives separately, in `help/i18n/enUS.ts` and `help/i18n/da.ts`: ```ts -export const enUS: Record = { ... }; +export const enUS: Record = { ... }; ``` `short` is one or two sentences and is what the ⓘ popover and the panel summary show. `detail` is an optional further paragraph shown only in the panel. +### Controls and tasks + +A **control** entry answers "what is this thing?" and is anchored to something on +screen. A **task** entry answers "how do I do X?", has ordered `steps`, and is anchored +to nothing. + +Both are needed, and the distinction is what makes the panel searchable. A planner who +needs to register vacation does not search for a control — no control on the page is +called "vacation". They search for the task. Control entries alone would return +nothing for the most common question the page receives. + +Tours are built from control entries only; tasks have no `anchor` and no `tourStep`. + `HelpContentService` resolves prose against ngx-translate's `currentLang` and falls back to English **per entry**, so a partially translated Danish file degrades entry-by-entry rather than all-or-nothing. @@ -110,6 +133,31 @@ into the browser top layer. This matters because roughly half the help lives ins `MatDialog`: a body-appended overlay would fight the dialog's own z-index and focus trap. Dismissed on Escape, backdrop click, and scroll. +**Search.** A field at the top of the panel, focused when the panel is opened from the +`?` button. With no query the panel shows its normal grouped browse view; search is +additive, never a replacement for browsing. + +Matching is case-insensitive and diacritic-insensitive — Danish `å æ ø` must fold, or +a user typing `laege` finds nothing — across `title`, `keywords`, `short`, `detail` and +`steps`. It runs against **both the active locale and the English fallback**, so a +Danish user who types an English term still finds the entry, and vice versa. Both are +already loaded, so this costs nothing. + +Matching is substring, not fuzzy. Across ~48 entries fuzzy matching adds noise rather +than recall. + +Results are ordered: **tasks before controls** — someone typing into a help box wants a +how-to, not a definition — then by where the match landed, title before keyword before +body. Each result shows its `title` and `short`; expanding a task reveals its `steps`. + +When nothing matches, the panel names the query and lists the tasks rather than showing +an empty result — a dead end is the one outcome a help search must not produce. + +`keywords` is what makes this work at all. A Danish planner types *ferie*, *sygdom*, +*fri*, *afspadsering* or *barsel*, and none of those strings appear anywhere in the +English prose. Keywords carry the synonyms, per locale, and are authored as +deliberately as the prose. + **Side panel.** A right-side slide-in opened by a single `?` button placed in the container toolbar after the last `div.line-vert` (`time-plannings-container.component.html:78`), styled @@ -151,14 +199,42 @@ The `dialog` tour is offered from inside `WorkdayEntityDialogComponent`. ## Entry inventory -Approximately 36 entries. +Approximately 48 entries — 36 controls and 12 tasks. + +### `task` (12) + +`registerVacation`, `registerSickness`, `registerDayOff`, `correctRegisteredTime`, +`addMissingRegistration`, `addExtraShift`, `changePlannedHours`, `payOutFlex`, +`exportForPayroll`, `whoChangedThis`, `whereWasThisRegistered`, `filterToOneTeam`. + +Each carries `steps` and a `related` list of the controls it touches, so a task ends by +pointing at the control entries that explain the fields it just told the user to fill. + +Three of these describe the day flags, and they carry a rule the UI actively hides. +The flags render as checkboxes but are **mutually exclusive** — ticking one unticks the +rest — and ticking one rewrites netto hours +(`workday-entity-dialog.component.ts:1263-1290`): + +| Flag | Resulting netto hours | +|---|---| +| `DayOff`, `VacationDayOff` | `0` | +| `Vacation`, `Sick`, `Course`, `LeaveOfAbsence`, `Maternity`, `Holiday`, and the rest | the day's planned hours | + +So `Vacation` and `VacationDayOff` sit next to each other and produce opposite results. +`registerVacation` and `registerDayOff` must state which one counts as worked time; +this is the single most valuable thing the help system can say. + +The full flag set is `TimePlanningMessagesEnum`: `DayOff`, `Vacation`, `Sick`, +`Course`, `LeaveOfAbsence`, `Children1stSick`, `Children2stSick`, `TimeOff`, +`Maternity`, `VacationDayOff`, `Holiday`, `PregnancyLeave`. `Blank` and `Care` are +excluded from the UI (`:241-242`) and get no entries. -### `toolbar` (9) +### `toolbar` — controls (9) `showResigned`, `navBackward`, `navForward`, `workerFilter`, `tagFilter`, `dateRange`, `downloadExcel`, `payrollExport` (adminOnly), `reload`. -### `grid` (8) +### `grid` — controls (8) `nameColumn`, `tagChips` (click-to-filter), `settingsStrip` (the pay-rule / mobile-registration / over-midnight / auto-break / one-minute / extra-shifts status @@ -169,7 +245,7 @@ badges), `dayCellAnatomy` (planned versus actual, and the icon legend), elements — a weekly total and a per-day-cell value. The help text must name which one it describes, or it will actively mislead. -### `dayCell` (16) +### `dayCell` — controls (16) `versionHistory`, `plannedTimes`, `actualTimes`, `shiftCount`, `resetField`, `resetPauseToRecorded`, `gps`, `snapshot`, `futureDisabled`, `planHours`, @@ -177,7 +253,7 @@ it describes, or it will actively mislead. `oneMinuteIntervals` (the timepicker's `minutesGap` is 1 or 5 depending on the worker's setting). -### `flex` (3) +### `flex` — controls (3) `whatIsFlex`, `sumFlex`, `paidOutFlexRelation`. @@ -222,10 +298,14 @@ Two further rules, both already exercised above: - **`HelpContentService` fallback** — an entry missing from `da.ts` resolves to the English text; a present entry does not. - **Registry integrity** — every entry with a `tourStep` also declares a `tour` and an - `anchor`; `tourStep` values are unique within each tour; every `helpId` referenced in + `anchor`; no `task` entry has an `anchor` or a `tourStep`; every `related` id resolves; + every entry has at least one keyword in `enUS`; `tourStep` values are unique within each tour; every `helpId` referenced in a template exists in the registry; every registry id has prose in `enUS`. This is the test that prevents rot: it fails when someone typos an id, or deletes a control and leaves its help entry behind. +- **Search** — a Danish query folds diacritics (`laege` finds *læge*); an English query + finds a Danish-only entry through the English fallback; tasks sort above controls; a + query matching nothing returns the task list rather than an empty result. - **Admin filtering** — a non-admin sees neither `adminOnly` entry in the panel, and the page tour skips the payroll step. From d529131fda49350f6c53b6cd5cc02e1ac6cfa9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:05:53 +0200 Subject: [PATCH 04/38] docs: add reviewed implementation plan for the planning help system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven tasks, TDD throughout, with the plugin cycle's dual-subagent pre-commit gate built into every commit step. Two reviewers ran against the first draft. Their findings, all fixed: The local test loop did not work at all. Jest runs from the frontend repo and its testMatch is scoped to that rootDir, so specs written only in the plugin repo were never discovered — every "run it and confirm it fails" step would have reported "No tests found". Task 0 now establishes the --roots invocation and the node_modules symlink it needs, both verified end to end before this plan was finalised. The plan also contradicted its own constraint by adding UI labels to the 25 shared locale files; those are now HelpUiStrings in help/i18n/, with tests asserting no help template uses the translate pipe. The dialog tour could never start, since mounting the component only subscribes. The panel's "replay the tour" affordance was specified but never built. HelpTourComponent marked a tour seen at mount — state$ replays null to new subscribers — which would have suppressed the automatic first run for every first-time user; recording moved into the service, guarded so a tour that could not start is not marked seen. Smaller fixes: dayCell.save and the three flex entries were never anchored; the "tasks rank above controls" test was vacuous when a query returned only tasks; the Danish banned-word regex missed administratoren and administratorens; three tests asserted prose the plan never mandates. Also corrects the spec: cdkConnectedOverlayUsePopover does not exist in CDK 20.2.14, several line citations were off by one, and the .help-text pattern is used twice in this plugin, not ~8 times. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../plans/2026-09-04-planning-help-system.md | 2657 +++++++++++++++++ .../2026-09-04-planning-help-system-design.md | 36 +- 2 files changed, 2682 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-04-planning-help-system.md diff --git a/docs/superpowers/plans/2026-09-04-planning-help-system.md b/docs/superpowers/plans/2026-09-04-planning-help-system.md new file mode 100644 index 00000000..228dc468 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-planning-help-system.md @@ -0,0 +1,2657 @@ +# Planning Help System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the TimePlanning planning page a searchable in-page help system — an ⓘ popover, a side panel with search, two guided tours, and inline hints — all fed by one content registry. + +**Architecture:** A registry of ~48 entries (36 control descriptions + 12 how-to tasks) defines structure only; prose lives in separate per-locale files and resolves through `HelpContentService` with per-entry English fallback. Four presentation surfaces read that one source. Everything lives inside the plugin under `time-planning-pn/help/`; nothing is added to the host frontend, the backend, or package.json. + +**Tech Stack:** Angular 20.3.17, Angular Material + CDK 20.2.14, `@ngx-translate/core` 17, NgModule (not standalone), Jest 30 via `@angular-builders/jest`, `mtx-grid` from `@ng-matero/extensions` 20.4.2. + +**Spec:** `docs/superpowers/specs/2026-09-04-planning-help-system-design.md` + +## Global Constraints + +- **Repository:** all edits go in `eform-angular-timeplanning-plugin`. Do **not** edit the host copy under `eform-angular-frontend/eform-client/src/app/plugins/modules/time-planning-pn/`, and do **not** run `devgetchanges.sh` — the host mirror is 68 files behind and running it would delete working features. +- **No new npm dependency.** No tour library, no search library. +- **No change** to the 25 existing locale files under `time-planning-pn/i18n/`, to `eform-angular-frontend`, or to any `-base` repo. This includes the help components' own button and section labels — they live in `help/i18n/` as `HelpUiStrings` and are read through `HelpContentService.ui()`, **not** through `TranslateService` or the `| translate` pipe. Nothing in `help/` may use the `translate` pipe. +- **`cdkConnectedOverlayUsePopover` does not exist in CDK 20.2.14.** Use a plain `cdkConnectedOverlay` with `cdkOverlayOrigin`. Verified against `node_modules/@angular/cdk/overlay-module.d.d.ts`. +- **Copy rule — "admin" means Microting, not a customer role.** No help string may mention administrator capabilities, explain what more access would allow, or account for why a control did nothing. Entries describe what the reader sees and what the reader can do. +- **Copy rule — name which value is meant** when a label is reused (`plannedHours` is both a weekly total and a per-cell value). +- **Copy rule — describe the screen, not the server.** Never state the flex formula; `SumFlexEnd − PaiedOutFlex` is duplicated in ~5 backend places and `PaiedOutFlexInSeconds` is often unpopulated. +- **Components are declared in the existing NgModule** `time-planning-pn.module.ts`. This plugin does not use standalone components. +- **Test command** (verified to work; see Task 0 for the one-time setup it needs): + + ```bash + cd /home/rene/Documents/workspace/microting/eform-angular-frontend/eform-client + npx jest --roots /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn \ + --testPathPatterns=help + ``` + + Jest must run from the **frontend** repo (that is where `jest.config.js`, the preset and `setup-jest.ts` live) but `--roots` points it at the **plugin** repo so it finds specs there. Without `--roots` it reports `No tests found` — its `testMatch: ['**/src/**/*.spec.ts']` is scoped to the frontend's own `rootDir`, and the plugin repo is a sibling path outside that tree. + + In CI this is unnecessary: the `angular-unit-test` job (`.github/workflows/dotnet-core-pr.yml:44-82`) copies the plugin folder into the frontend checkout (line 64) before running `npm run test:unit -- --testPathPatterns=time-planning-pn`. Specs therefore run in CI automatically — unlike the dotnet shards, there is no allowlist to update. +- **Pre-commit gate (mandatory, every task):** dispatch `pr-review-toolkit:code-reviewer` and `code-simplifier:code-simplifier` **in parallel, in one message**, on the task's diff. Resolve or consciously dismiss every finding before committing. If you edit after their feedback, re-run both on the new diff. +- **Branch:** `feat/planning-help-system`, PR toward `stable`. Never commit to `stable` or `master` directly. + +--- + +## File Structure + +All paths relative to `eform-client/src/app/plugins/modules/time-planning-pn/`. + +| File | Responsibility | +|---|---| +| `help/help.model.ts` | Types: `HelpEntryId`, `HelpKind`, `HelpSection`, `HelpEntry`, `HelpProse`, `HelpProseMap` | +| `help/planning-help.registry.ts` | The 48 entries — structure only, no prose | +| `help/i18n/enUS.ts` | English prose, complete | +| `help/i18n/da.ts` | Danish prose, complete | +| `help/i18n/index.ts` | `HELP_LOCALES` map from locale code to prose map | +| `help/services/help-content.service.ts` | Locale resolution + per-entry English fallback | +| `help/services/help-search.service.ts` | Diacritic folding, matching, ranking | +| `help/services/help-panel.service.ts` | Panel open/close state and deep-link target | +| `help/services/help-tour.service.ts` | Tour sequencing, anchor lookup, seen-once | +| `help/components/help-icon/` | `tp-help-icon` — ⓘ button + popover | +| `help/components/help-hint/` | `tp-help-hint` — inline `.help-text` hint | +| `help/components/help-panel/` | `tp-help-panel` — side panel, browse + search | +| `help/components/help-tour/` | `tp-help-tour` — tour step overlay | + +Section values actually used are `task`, `toolbar`, `grid`, `dayCell`, `flex`. The spec's type union also listed `shifts` and `flags`; no entry uses them, so they are omitted here. + +--- + +### Task 0: Make the plugin repo testable + +Jest lives in the frontend repo; the specs live here. TypeScript resolves `@angular/*` +imports by walking up from the file, and the plugin repo has no `node_modules`, so +without this step every spec fails with `TS2307: Cannot find module '@angular/core'`. + +**Files:** +- Modify: `.gitignore` + +**Interfaces:** none. + +- [ ] **Step 1: Symlink the frontend's `node_modules`** + +```bash +ln -s /home/rene/Documents/workspace/microting/eform-angular-frontend/eform-client/node_modules \ + /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/node_modules +``` + +- [ ] **Step 2: Ignore it** + +`.gitignore` line 276 is `node_modules/` — with the trailing slash it matches directories +only, and this is a symlink, so it is **not** covered. Append: + +``` +eform-client/node_modules +``` + +- [ ] **Step 3: Prove the loop works before writing any feature code** + +Create `help/__setup-check.spec.ts`: + +```ts +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { OverlayModule } from '@angular/cdk/overlay'; + +@Component({ selector: 'tp-setup-check', template: '{{ label }}', standalone: false }) +class SetupCheckComponent { label = 'ok'; } + +describe('jest setup', () => { + it('compiles an NgModule-declared component from the plugin repo', async () => { + await TestBed.configureTestingModule({ + declarations: [SetupCheckComponent], + imports: [OverlayModule], + }).compileComponents(); + const fixture = TestBed.createComponent(SetupCheckComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('ok'); + }); +}); +``` + +Run the test command from Global Constraints. Expected: PASS, 1 test. If it reports +`No tests found`, `--roots` is wrong. If it reports `TS2307`, the symlink is missing. + +- [ ] **Step 4: Delete `help/__setup-check.spec.ts`** — it has served its purpose. + +- [ ] **Step 5: Commit** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add .gitignore +git commit -m "chore: ignore the local node_modules symlink used for running plugin tests" +``` + +--- + +### Task 1: Types, registry, and the integrity test + +**Files:** +- Create: `help/help.model.ts` +- Create: `help/planning-help.registry.ts` +- Create: `help/i18n/enUS.ts` (ids + placeholder-free English prose for all 48) +- Create: `help/i18n/index.ts` +- Test: `help/planning-help.registry.spec.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `HelpEntryId` (string-literal union), `HelpKind`, `HelpSection`, `HelpEntry`, `HelpProse`, `HelpProseMap`, `PLANNING_HELP_ENTRIES: HelpEntry[]`, `HELP_LOCALES: Record>`, `enUS: HelpProseMap`. + +- [ ] **Step 1: Write the failing integrity test** + +Create `help/planning-help.registry.spec.ts`: + +```ts +import { PLANNING_HELP_ENTRIES } from './planning-help.registry'; +import { enUS } from './i18n/enUS'; +import { HelpEntry, HelpEntryId } from './help.model'; + +describe('planning help registry', () => { + const byId = new Map( + PLANNING_HELP_ENTRIES.map(e => [e.id, e]), + ); + + it('has no duplicate ids', () => { + expect(byId.size).toBe(PLANNING_HELP_ENTRIES.length); + }); + + it('gives every entry English prose with a title, short text and a keyword', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = enUS[entry.id]; + expect(prose).toBeDefined(); + expect(prose.title.length).toBeGreaterThan(0); + expect(prose.short.length).toBeGreaterThan(0); + expect(prose.keywords.length).toBeGreaterThan(0); + } + }); + + it('gives every task steps, and no control steps', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = enUS[entry.id]; + if (entry.kind === 'task') { + expect(prose.steps?.length ?? 0).toBeGreaterThan(0); + } else { + expect(prose.steps).toBeUndefined(); + } + } + }); + + it('keeps tasks out of tours and off the page', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.kind === 'task')) { + expect(entry.anchor).toBeUndefined(); + expect(entry.tourStep).toBeUndefined(); + expect(entry.tour).toBeUndefined(); + } + }); + + it('gives every tour step a tour and an anchor', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.tourStep !== undefined)) { + expect(entry.tour).toBeDefined(); + expect(entry.anchor).toBeDefined(); + } + }); + + it('numbers tour steps uniquely within each tour', () => { + for (const tour of ['page', 'dialog'] as const) { + const steps = PLANNING_HELP_ENTRIES + .filter(e => e.tour === tour && e.tourStep !== undefined) + .map(e => e.tourStep as number); + expect(new Set(steps).size).toBe(steps.length); + expect(steps.length).toBeGreaterThan(0); + } + }); + + it('resolves every related id', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + for (const related of entry.related ?? []) { + expect(byId.has(related)).toBe(true); + } + } + }); + + it('marks exactly one entry admin-only', () => { + const adminOnly = PLANNING_HELP_ENTRIES.filter(e => e.adminOnly); + expect(adminOnly.map(e => e.id)).toEqual(['toolbar.payrollExport']); + }); + + it('never mentions administrators in user-facing copy', () => { + const banned = /\badmin(istrator)?s?\b/i; + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = enUS[entry.id]; + const text = [prose.title, prose.short, prose.detail ?? '', ...(prose.steps ?? [])].join(' '); + expect(text).not.toMatch(banned); + } + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './planning-help.registry'`. + +- [ ] **Step 3: Write `help/help.model.ts`** + +```ts +export const HELP_IDS = [ + // tasks + 'task.registerVacation', 'task.registerSickness', 'task.registerDayOff', + 'task.correctRegisteredTime', 'task.addMissingRegistration', 'task.addExtraShift', + 'task.changePlannedHours', 'task.payOutFlex', 'task.exportForPayroll', + 'task.whoChangedThis', 'task.whereWasThisRegistered', 'task.filterToOneTeam', + // toolbar controls + 'toolbar.showResigned', 'toolbar.navBackward', 'toolbar.navForward', + 'toolbar.workerFilter', 'toolbar.tagFilter', 'toolbar.dateRange', + 'toolbar.downloadExcel', 'toolbar.payrollExport', 'toolbar.reload', + // grid controls + 'grid.nameColumn', 'grid.tagChips', 'grid.settingsStrip', 'grid.dayCellAnatomy', + 'grid.weeklyPlannedHours', 'grid.messageIcons', 'grid.sortName', 'grid.openDay', + // day-cell dialog controls + 'dayCell.versionHistory', 'dayCell.plannedTimes', 'dayCell.actualTimes', + 'dayCell.shiftCount', 'dayCell.resetField', 'dayCell.resetPauseToRecorded', + 'dayCell.gps', 'dayCell.snapshot', 'dayCell.futureDisabled', 'dayCell.planHours', + 'dayCell.nettoOverride', 'dayCell.paidOutFlex', 'dayCell.flags', + 'dayCell.commentOffice', 'dayCell.save', 'dayCell.oneMinuteIntervals', + // flex controls + 'flex.whatIsFlex', 'flex.sumFlex', 'flex.paidOutFlexRelation', +] as const; + +export type HelpEntryId = typeof HELP_IDS[number]; +export type HelpKind = 'control' | 'task'; +export type HelpSection = 'task' | 'toolbar' | 'grid' | 'dayCell' | 'flex'; +export type HelpTourName = 'page' | 'dialog'; + +export interface HelpEntry { + id: HelpEntryId; + kind: HelpKind; + section: HelpSection; + /** data-tp-help value on the element this entry describes. Controls only. */ + anchor?: string; + tour?: HelpTourName; + tourStep?: number; + adminOnly?: boolean; + /** Tasks only: the controls this task touches. */ + related?: HelpEntryId[]; +} + +export interface HelpProse { + title: string; + short: string; + detail?: string; + /** Tasks only, in order. */ + steps?: string[]; + /** Search synonyms, in this locale's language. */ + keywords: string[]; +} + +export type HelpProseMap = Record; + +/** + * Labels for the help components' own chrome. These live here rather than in the + * plugin's 25 shared locale files, which this work must not touch. + */ +export interface HelpUiStrings { + help: string; + searchHelp: string; + clear: string; + close: string; + moreInHelp: string; + replayTour: string; + skip: string; + next: string; + noResults: string; + sectionTask: string; + sectionToolbar: string; + sectionGrid: string; + sectionDayCell: string; + sectionFlex: string; +} + +export type HelpUiKey = keyof HelpUiStrings; +``` + +- [ ] **Step 4: Write `help/planning-help.registry.ts`** + +Every id in `HELP_IDS` gets exactly one entry. Tasks carry `related` and no anchor; controls carry `anchor`; eight controls carry `tour: 'page'` with `tourStep` 1-8 and six carry `tour: 'dialog'` with `tourStep` 1-6. + +```ts +import { HelpEntry } from './help.model'; + +export const PLANNING_HELP_ENTRIES: HelpEntry[] = [ + // ---- tasks (no anchor, no tour) ---- + { id: 'task.registerVacation', kind: 'task', section: 'task', + related: ['dayCell.flags', 'dayCell.nettoOverride', 'dayCell.save'] }, + { id: 'task.registerSickness', kind: 'task', section: 'task', + related: ['dayCell.flags', 'dayCell.save'] }, + { id: 'task.registerDayOff', kind: 'task', section: 'task', + related: ['dayCell.flags', 'dayCell.nettoOverride'] }, + { id: 'task.correctRegisteredTime', kind: 'task', section: 'task', + related: ['dayCell.actualTimes', 'dayCell.resetField', 'dayCell.save'] }, + { id: 'task.addMissingRegistration', kind: 'task', section: 'task', + related: ['grid.openDay', 'dayCell.actualTimes', 'dayCell.save'] }, + { id: 'task.addExtraShift', kind: 'task', section: 'task', + related: ['dayCell.shiftCount', 'grid.settingsStrip'] }, + { id: 'task.changePlannedHours', kind: 'task', section: 'task', + related: ['dayCell.plannedTimes', 'dayCell.planHours'] }, + { id: 'task.payOutFlex', kind: 'task', section: 'task', + related: ['dayCell.paidOutFlex', 'flex.sumFlex'] }, + { id: 'task.exportForPayroll', kind: 'task', section: 'task', + related: ['toolbar.downloadExcel'] }, + { id: 'task.whoChangedThis', kind: 'task', section: 'task', + related: ['dayCell.versionHistory'] }, + { id: 'task.whereWasThisRegistered', kind: 'task', section: 'task', + related: ['dayCell.gps', 'dayCell.snapshot'] }, + { id: 'task.filterToOneTeam', kind: 'task', section: 'task', + related: ['toolbar.tagFilter', 'grid.tagChips'] }, + + // ---- toolbar ---- + { id: 'toolbar.showResigned', kind: 'control', section: 'toolbar', anchor: 'toolbar.showResigned' }, + { id: 'toolbar.navBackward', kind: 'control', section: 'toolbar', anchor: 'toolbar.navBackward' }, + { id: 'toolbar.navForward', kind: 'control', section: 'toolbar', anchor: 'toolbar.navForward', + tour: 'page', tourStep: 2 }, + { id: 'toolbar.workerFilter', kind: 'control', section: 'toolbar', anchor: 'toolbar.workerFilter', + tour: 'page', tourStep: 3 }, + { id: 'toolbar.tagFilter', kind: 'control', section: 'toolbar', anchor: 'toolbar.tagFilter' }, + { id: 'toolbar.dateRange', kind: 'control', section: 'toolbar', anchor: 'toolbar.dateRange', + tour: 'page', tourStep: 1 }, + { id: 'toolbar.downloadExcel', kind: 'control', section: 'toolbar', anchor: 'toolbar.downloadExcel', + tour: 'page', tourStep: 7 }, + { id: 'toolbar.payrollExport', kind: 'control', section: 'toolbar', anchor: 'toolbar.payrollExport', + tour: 'page', tourStep: 8, adminOnly: true }, + { id: 'toolbar.reload', kind: 'control', section: 'toolbar', anchor: 'toolbar.reload' }, + + // ---- grid ---- + { id: 'grid.nameColumn', kind: 'control', section: 'grid', anchor: 'grid.nameColumn', + tour: 'page', tourStep: 4 }, + { id: 'grid.tagChips', kind: 'control', section: 'grid', anchor: 'grid.tagChips' }, + { id: 'grid.settingsStrip', kind: 'control', section: 'grid', anchor: 'grid.settingsStrip' }, + { id: 'grid.dayCellAnatomy', kind: 'control', section: 'grid', anchor: 'grid.dayCellAnatomy', + tour: 'page', tourStep: 5 }, + { id: 'grid.weeklyPlannedHours', kind: 'control', section: 'grid', anchor: 'grid.weeklyPlannedHours' }, + { id: 'grid.messageIcons', kind: 'control', section: 'grid', anchor: 'grid.messageIcons' }, + { id: 'grid.sortName', kind: 'control', section: 'grid', anchor: 'grid.sortName' }, + { id: 'grid.openDay', kind: 'control', section: 'grid', anchor: 'grid.openDay', + tour: 'page', tourStep: 6 }, + + // ---- day-cell dialog ---- + { id: 'dayCell.versionHistory', kind: 'control', section: 'dayCell', anchor: 'dayCell.versionHistory' }, + { id: 'dayCell.plannedTimes', kind: 'control', section: 'dayCell', anchor: 'dayCell.plannedTimes', + tour: 'dialog', tourStep: 1 }, + { id: 'dayCell.actualTimes', kind: 'control', section: 'dayCell', anchor: 'dayCell.actualTimes', + tour: 'dialog', tourStep: 2 }, + { id: 'dayCell.shiftCount', kind: 'control', section: 'dayCell', anchor: 'dayCell.shiftCount' }, + { id: 'dayCell.resetField', kind: 'control', section: 'dayCell', anchor: 'dayCell.resetField' }, + { id: 'dayCell.resetPauseToRecorded', kind: 'control', section: 'dayCell', anchor: 'dayCell.resetPauseToRecorded' }, + { id: 'dayCell.gps', kind: 'control', section: 'dayCell', anchor: 'dayCell.gps' }, + { id: 'dayCell.snapshot', kind: 'control', section: 'dayCell', anchor: 'dayCell.snapshot' }, + { id: 'dayCell.futureDisabled', kind: 'control', section: 'dayCell', anchor: 'dayCell.futureDisabled' }, + { id: 'dayCell.planHours', kind: 'control', section: 'dayCell', anchor: 'dayCell.planHours', + tour: 'dialog', tourStep: 3 }, + { id: 'dayCell.nettoOverride', kind: 'control', section: 'dayCell', anchor: 'dayCell.nettoOverride', + tour: 'dialog', tourStep: 5 }, + { id: 'dayCell.paidOutFlex', kind: 'control', section: 'dayCell', anchor: 'dayCell.paidOutFlex' }, + { id: 'dayCell.flags', kind: 'control', section: 'dayCell', anchor: 'dayCell.flags', + tour: 'dialog', tourStep: 4 }, + { id: 'dayCell.commentOffice', kind: 'control', section: 'dayCell', anchor: 'dayCell.commentOffice' }, + { id: 'dayCell.save', kind: 'control', section: 'dayCell', anchor: 'dayCell.save', + tour: 'dialog', tourStep: 6 }, + { id: 'dayCell.oneMinuteIntervals', kind: 'control', section: 'dayCell', anchor: 'dayCell.oneMinuteIntervals' }, + + // ---- flex ---- + { id: 'flex.whatIsFlex', kind: 'control', section: 'flex', anchor: 'flex.whatIsFlex' }, + { id: 'flex.sumFlex', kind: 'control', section: 'flex', anchor: 'flex.sumFlex' }, + { id: 'flex.paidOutFlexRelation', kind: 'control', section: 'flex', anchor: 'flex.paidOutFlexRelation' }, +]; +``` + +- [ ] **Step 5: Write `help/i18n/enUS.ts` — all 48 entries** + +Authoring rules, enforced by the tests above: no entry may contain the word "admin"/"administrator"; every entry needs `keywords`; tasks need `steps`; controls must not have `steps`. + +The three leave tasks carry the rule the UI hides — the day flags render as checkboxes but are mutually exclusive, and ticking one rewrites netto hours (`workday-entity-dialog.component.ts:1263-1290`): `DayOff` and `VacationDayOff` set netto to `0`; every other flag sets it to the day's planned hours. + +```ts +import { HelpProseMap } from '../help.model'; + +export const enUS: HelpProseMap = { + 'task.registerVacation': { + title: 'Register vacation for a worker', + short: 'Mark a day as vacation. The day still counts as the hours the worker was planned to work.', + steps: [ + 'Click the day in the grid where the vacation starts.', + 'Tick Vacation in the list of day types.', + 'Click Save. The day now counts as the planned hours.', + 'Repeat for each vacation day.', + ], + detail: 'A day carries one day type at a time — ticking Vacation clears any other type already set. Use Vacation day off instead if the day should count as zero hours.', + keywords: ['vacation', 'holiday', 'time off', 'leave', 'absent', 'away'], + }, + 'task.registerDayOff': { + title: 'Register a day off', + short: 'Mark a day as a day off. Unlike vacation, the day counts as zero hours.', + steps: [ + 'Click the day in the grid.', + 'Tick Day off, or Vacation day off if it comes out of the vacation balance.', + 'Click Save. The day now counts as zero hours.', + ], + detail: 'Day off and Vacation day off both set the day to zero hours. Vacation, sickness, course and the other day types keep the planned hours instead. This is the difference to watch for.', + keywords: ['day off', 'off', 'free', 'not working', 'zero hours', 'vacation day off'], + }, + 'dayCell.flags': { + title: 'Day type', + short: 'Marks what kind of day this is — vacation, sickness, course, and so on. A day carries one type at a time; ticking a new one clears the previous.', + detail: 'Setting a day type also sets the netto hours for that day. Day off and Vacation day off set it to zero; every other type sets it to the hours planned for that day. Clearing the type removes that override.', + keywords: ['day type', 'vacation', 'sickness', 'sick', 'course', 'maternity', 'leave', 'holiday', 'flag', 'absence'], + }, + // ... the remaining 45 entries, in the same shape and to the same rules. +}; + +export const enUSUi: HelpUiStrings = { + help: 'Help', + searchHelp: 'Search help', + clear: 'Clear', + close: 'Close', + moreInHelp: 'More in help', + replayTour: 'Take the tour', + skip: 'Skip', + next: 'Next', + noResults: 'Nothing matched. Here is what people usually need:', + sectionTask: 'Common tasks', + sectionToolbar: 'Toolbar', + sectionGrid: 'The grid', + sectionDayCell: 'Editing a day', + sectionFlex: 'Flex', +}; +``` + +Import `HelpUiStrings` alongside `HelpProseMap` at the top of the file. + +Write all 48. The registry spec fails until every id has prose, so completeness is enforced, not trusted. + +- [ ] **Step 6: Write `help/i18n/index.ts`** + +```ts +import { HelpProseMap, HelpUiStrings } from '../help.model'; +import { enUS, enUSUi } from './enUS'; + +/** Locale code (as ngx-translate reports it) to prose. Partial maps fall back per entry. */ +export const HELP_LOCALES: Record> = { + 'en-US': enUS, +}; + +export const HELP_UI_LOCALES: Record = { + 'en-US': enUSUi, +}; + +export const HELP_FALLBACK: HelpProseMap = enUS; +export const HELP_UI_FALLBACK: HelpUiStrings = enUSUi; +``` + +- [ ] **Step 7: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS, 9 tests. + +- [ ] **Step 8: Pre-commit gate** + +Dispatch `pr-review-toolkit:code-reviewer` and `code-simplifier:code-simplifier` in parallel on the diff. Resolve findings. + +- [ ] **Step 9: Commit** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/help +git commit -m "feat(help): add planning help registry, types and English content" +``` + +--- + +### Task 2: HelpContentService + +**Files:** +- Create: `help/services/help-content.service.ts` +- Test: `help/services/help-content.service.spec.ts` + +**Interfaces:** +- Consumes: `PLANNING_HELP_ENTRIES`, `HELP_LOCALES`, `HELP_FALLBACK`, `HelpEntry`, `HelpEntryId`, `HelpProse` from Task 1. +- Produces: `HelpContentService` with `entry(id: HelpEntryId): HelpEntry | undefined`, `prose(id: HelpEntryId): HelpProse`, `entries(opts: { isAdmin: boolean }): HelpEntry[]`, `tourEntries(tour: HelpTourName, opts: { isAdmin: boolean }): HelpEntry[]`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpContentService } from './help-content.service'; +import { enUS } from '../i18n/enUS'; + +describe('HelpContentService', () => { + let translate: { currentLang: string }; + + const make = (lang: string) => { + translate = { currentLang: lang }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + HelpContentService, + { provide: TranslateService, useValue: translate }, + ], + }); + return TestBed.inject(HelpContentService); + }; + + // These compare against the content file rather than a hard-coded string, so a + // copywriting choice made later in Task 1 cannot fail a resolution test. + it('returns English prose for an English locale', () => { + const service = make('en-US'); + expect(service.prose('toolbar.dateRange')).toEqual(enUS['toolbar.dateRange']); + }); + + it('falls back to English for a locale with no prose file', () => { + const service = make('de-DE'); + expect(service.prose('toolbar.dateRange')).toEqual(enUS['toolbar.dateRange']); + }); + + it('resolves a bare language code to its locale file', () => { + const service = make('da'); + expect(service.prose('toolbar.dateRange')).toBeDefined(); + }); + + it('hides admin-only entries from a non-admin', () => { + const service = make('en-US'); + const ids = service.entries({ isAdmin: false }).map(e => e.id); + expect(ids).not.toContain('toolbar.payrollExport'); + expect(service.entries({ isAdmin: true }).map(e => e.id)) + .toContain('toolbar.payrollExport'); + }); + + it('orders tour entries by step and drops admin-only steps for a non-admin', () => { + const service = make('en-US'); + const steps = service.tourEntries('page', { isAdmin: false }); + expect(steps.map(e => e.tourStep)).toEqual([...steps.map(e => e.tourStep)].sort((a, b) => (a ?? 0) - (b ?? 0))); + expect(steps.map(e => e.id)).not.toContain('toolbar.payrollExport'); + expect(service.tourEntries('page', { isAdmin: true }).map(e => e.id)) + .toContain('toolbar.payrollExport'); + }); + + it('never returns undefined prose for a registry id', () => { + const service = make('da'); + for (const entry of service.entries({ isAdmin: true })) { + expect(service.prose(entry.id).short.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './help-content.service'`. + +- [ ] **Step 3: Implement the service** + +```ts +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpEntry, HelpEntryId, HelpProse, HelpTourName, HelpUiStrings } from '../help.model'; +import { PLANNING_HELP_ENTRIES } from '../planning-help.registry'; +import { HELP_FALLBACK, HELP_LOCALES, HELP_UI_FALLBACK, HELP_UI_LOCALES } from '../i18n'; + +@Injectable({ providedIn: 'root' }) +export class HelpContentService { + private readonly byId = new Map( + PLANNING_HELP_ENTRIES.map(entry => [entry.id, entry]), + ); + + constructor(private translateService: TranslateService) {} + + entry(id: HelpEntryId): HelpEntry | undefined { + return this.byId.get(id); + } + + /** Active locale, falling back to English one entry at a time. */ + prose(id: HelpEntryId): HelpProse { + return this.localeProse()[id] ?? HELP_FALLBACK[id]; + } + + entries(opts: { isAdmin: boolean }): HelpEntry[] { + return PLANNING_HELP_ENTRIES.filter(entry => !entry.adminOnly || opts.isAdmin); + } + + tourEntries(tour: HelpTourName, opts: { isAdmin: boolean }): HelpEntry[] { + return this.entries(opts) + .filter(entry => entry.tour === tour && entry.tourStep !== undefined) + .sort((a, b) => (a.tourStep as number) - (b.tourStep as number)); + } + + /** Chrome labels for the help components, resolved the same way as prose. */ + ui(): HelpUiStrings { + const lang = this.lang(); + return HELP_UI_LOCALES[lang] ?? HELP_UI_LOCALES[lang.split('-')[0]] ?? HELP_UI_FALLBACK; + } + + private localeProse(): Partial> { + const lang = this.lang(); + return HELP_LOCALES[lang] ?? HELP_LOCALES[lang.split('-')[0]] ?? HELP_FALLBACK; + } + + private lang(): string { + return this.translateService.currentLang || 'en-US'; + } +} +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. The `'da'` cases pass against the English fallback until Task 3 adds the Danish file. + +- [ ] **Step 5: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 6: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help/services +git commit -m "feat(help): resolve help prose by locale with per-entry English fallback" +``` + +--- + +### Task 3: Danish content + +**Files:** +- Create: `help/i18n/da.ts` +- Modify: `help/i18n/index.ts` +- Test: `help/i18n/da.spec.ts` + +**Interfaces:** +- Consumes: `HelpProseMap`, `HELP_IDS`, `enUS` from Task 1. +- Produces: `da: HelpProseMap`, registered in `HELP_LOCALES` under `'da'`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { da } from './da'; +import { enUS } from './enUS'; +import { HELP_IDS } from '../help.model'; +import { PLANNING_HELP_ENTRIES } from '../planning-help.registry'; + +describe('Danish help content', () => { + it('covers every registry id', () => { + for (const id of HELP_IDS) { + expect(da[id]).toBeDefined(); + } + }); + + it('is actually translated, not copied from English', () => { + const identical = HELP_IDS.filter(id => da[id].short === enUS[id].short); + expect(identical).toEqual([]); + }); + + it('gives every task Danish steps', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.kind === 'task')) { + expect(da[entry.id].steps?.length ?? 0).toBeGreaterThan(0); + } + }); + + it('carries Danish search keywords the English file does not have', () => { + const danish = new Set(HELP_IDS.flatMap(id => da[id].keywords)); + for (const word of ['ferie', 'sygdom', 'fri', 'afspadsering', 'barsel']) { + expect(danish.has(word)).toBe(true); + } + }); + + it('never mentions administrators', () => { + // \w* catches the Danish definite and possessive forms — administratoren, + // administratorens — which a content author is most likely to reach for. + const banned = /\badministrator\w*\b|\badmin\b/i; + for (const id of HELP_IDS) { + const prose = da[id]; + const text = [prose.title, prose.short, prose.detail ?? '', ...(prose.steps ?? [])].join(' '); + expect(text).not.toMatch(banned); + } + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './da'`. + +- [ ] **Step 3: Write `help/i18n/da.ts` — all 48 entries in Danish** + +```ts +import { HelpProseMap } from '../help.model'; + +export const da: HelpProseMap = { + 'task.registerVacation': { + title: 'Registrér ferie for en medarbejder', + short: 'Markér en dag som ferie. Dagen tæller stadig som de timer, medarbejderen var planlagt til.', + steps: [ + 'Klik på dagen i skemaet, hvor ferien begynder.', + 'Sæt flueben ved Ferie.', + 'Klik Gem. Dagen tæller nu som de planlagte timer.', + 'Gentag for hver feriedag.', + ], + detail: 'En dag har én dagtype ad gangen — sætter du Ferie, fjernes en anden type, der måtte være sat. Brug Feriefridag i stedet, hvis dagen skal tælle som nul timer.', + keywords: ['ferie', 'fri', 'fravær', 'orlov', 'væk', 'feriedag'], + }, + 'task.registerDayOff': { + title: 'Registrér en fridag', + short: 'Markér en dag som fridag. Modsat ferie tæller dagen som nul timer.', + steps: [ + 'Klik på dagen i skemaet.', + 'Sæt flueben ved Fridag, eller Feriefridag hvis dagen trækkes fra ferien.', + 'Klik Gem. Dagen tæller nu som nul timer.', + ], + detail: 'Fridag og Feriefridag sætter begge dagen til nul timer. Ferie, sygdom, kursus og de øvrige dagtyper beholder de planlagte timer. Det er forskellen, man skal være opmærksom på.', + keywords: ['fridag', 'fri', 'afspadsering', 'nul timer', 'feriefridag', 'ikke på arbejde'], + }, + // ... the remaining 46 entries. +}; + +export const daUi: HelpUiStrings = { + help: 'Hjælp', + searchHelp: 'Søg i hjælp', + clear: 'Ryd', + close: 'Luk', + moreInHelp: 'Mere i hjælp', + replayTour: 'Tag rundvisningen', + skip: 'Spring over', + next: 'Næste', + noResults: 'Ingen træffere. Her er det, folk oftest har brug for:', + sectionTask: 'Almindelige opgaver', + sectionToolbar: 'Værktøjslinje', + sectionGrid: 'Skemaet', + sectionDayCell: 'Rediger dag', + sectionFlex: 'Flex', +}; +``` + +Register both in `help/i18n/index.ts`: add `'da': da` to `HELP_LOCALES` and `'da': daUi` +to `HELP_UI_LOCALES`. + +- [ ] **Step 4: Register the locale in `help/i18n/index.ts`** + +```ts +import { HelpProseMap, HelpUiStrings } from '../help.model'; +import { enUS, enUSUi } from './enUS'; +import { da, daUi } from './da'; + +export const HELP_LOCALES: Record> = { + 'en-US': enUS, + 'da': da, +}; + +export const HELP_UI_LOCALES: Record = { + 'en-US': enUSUi, + 'da': daUi, +}; + +export const HELP_FALLBACK: HelpProseMap = enUS; +export const HELP_UI_FALLBACK: HelpUiStrings = enUSUi; +``` + +- [ ] **Step 5: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. + +- [ ] **Step 6: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 7: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help/i18n +git commit -m "feat(help): add Danish help content and search keywords" +``` + +--- + +### Task 4: HelpSearchService + +**Files:** +- Create: `help/services/help-search.service.ts` +- Test: `help/services/help-search.service.spec.ts` + +**Interfaces:** +- Consumes: `HelpContentService` (Task 2), `HELP_FALLBACK`, `HelpEntry`, `HelpProse`. +- Produces: `HelpSearchService` with `search(query: string, opts: { isAdmin: boolean }): HelpSearchResult[]`, and the exported interface `HelpSearchResult { entry: HelpEntry; prose: HelpProse; }`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpSearchService } from './help-search.service'; +import { HelpContentService } from './help-content.service'; + +describe('HelpSearchService', () => { + const make = (lang: string) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + HelpSearchService, + HelpContentService, + { provide: TranslateService, useValue: { currentLang: lang } }, + ], + }); + return TestBed.inject(HelpSearchService); + }; + + it('finds the vacation task from the Danish word', () => { + const ids = make('da').search('ferie', { isAdmin: false }).map(r => r.entry.id); + expect(ids).toContain('task.registerVacation'); + }); + + it('finds a Danish entry from an English word, through the fallback', () => { + const ids = make('da').search('vacation', { isAdmin: false }).map(r => r.entry.id); + expect(ids).toContain('task.registerVacation'); + }); + + it('folds diacritics so ae matches æ', () => { + const service = make('da'); + const withLigature = service.search('læge', { isAdmin: false }).map(r => r.entry.id); + const folded = service.search('laege', { isAdmin: false }).map(r => r.entry.id); + expect(folded).toEqual(withLigature); + }); + + it('folds ø and å', () => { + const service = make('da'); + expect(service.search('sygdom', { isAdmin: false }).length).toBeGreaterThan(0); + expect(service.search('arstid', { isAdmin: false })).toEqual( + service.search('årstid', { isAdmin: false }), + ); + }); + + it('ranks tasks above controls', () => { + const results = make('en-US').search('vacation', { isAdmin: false }); + const firstControl = results.findIndex(r => r.entry.kind === 'control'); + const lastTask = results.map(r => r.entry.kind).lastIndexOf('task'); + // Assert both groups are present, so a content edit that removes one cannot + // make this test pass without checking anything. + expect(firstControl).not.toBe(-1); + expect(lastTask).not.toBe(-1); + expect(lastTask).toBeLessThan(firstControl); + }); + + it('ranks a title match above a body-only match', () => { + const results = make('en-US').search('flex', { isAdmin: false }); + expect(results.length).toBeGreaterThan(1); + expect(results[0].prose.title.toLowerCase()).toContain('flex'); + }); + + it('returns the task list when nothing matches', () => { + const results = make('en-US').search('zzzznomatch', { isAdmin: false }); + expect(results.length).toBeGreaterThan(0); + expect(results.every(r => r.entry.kind === 'task')).toBe(true); + }); + + it('returns the task list for an empty query', () => { + const results = make('en-US').search(' ', { isAdmin: false }); + expect(results.every(r => r.entry.kind === 'task')).toBe(true); + }); + + it('never returns an admin-only entry to a non-admin', () => { + const ids = make('en-US').search('payroll', { isAdmin: false }).map(r => r.entry.id); + expect(ids).not.toContain('toolbar.payrollExport'); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './help-search.service'`. + +- [ ] **Step 3: Implement the service** + +`ø` and `æ` have no Unicode decomposition, so NFD alone does not fold them — they need an explicit map. `å` does decompose, and the combining-mark strip handles it. + +```ts +import { Injectable } from '@angular/core'; +import { HelpEntry, HelpEntryId, HelpProse } from '../help.model'; +import { HELP_FALLBACK } from '../i18n'; +import { HelpContentService } from './help-content.service'; + +export interface HelpSearchResult { + entry: HelpEntry; + prose: HelpProse; +} + +/** Match location, lower is better. */ +const RANK_TITLE = 0; +const RANK_KEYWORD = 1; +const RANK_BODY = 2; +const RANK_NONE = 99; + +const LIGATURES: Record = { æ: 'ae', ø: 'o', Æ: 'ae', Ø: 'o' }; + +export function fold(value: string): string { + return value + .replace(/[æøÆØ]/g, char => LIGATURES[char]) + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); +} + +@Injectable({ providedIn: 'root' }) +export class HelpSearchService { + constructor(private helpContent: HelpContentService) {} + + search(query: string, opts: { isAdmin: boolean }): HelpSearchResult[] { + const needle = fold(query); + const entries = this.helpContent.entries(opts); + + if (!needle) { + return this.tasksOnly(entries); + } + + const ranked = entries + .map(entry => ({ entry, prose: this.helpContent.prose(entry.id), rank: this.rank(entry.id, needle) })) + .filter(result => result.rank !== RANK_NONE); + + if (!ranked.length) { + return this.tasksOnly(entries); + } + + return ranked + .sort((a, b) => + (a.entry.kind === 'task' ? 0 : 1) - (b.entry.kind === 'task' ? 0 : 1) || + a.rank - b.rank) + .map(({ entry, prose }) => ({ entry, prose })); + } + + /** Best match location across the active locale and the English fallback. */ + private rank(id: HelpEntryId, needle: string): number { + const candidates = [this.helpContent.prose(id), HELP_FALLBACK[id]]; + let best = RANK_NONE; + + for (const prose of candidates) { + if (fold(prose.title).includes(needle)) { + return RANK_TITLE; + } + if (prose.keywords.some(keyword => fold(keyword).includes(needle))) { + // A keyword match already beats any body match, so skip the body scan. + best = Math.min(best, RANK_KEYWORD); + continue; + } + const body = [prose.short, prose.detail ?? '', ...(prose.steps ?? [])].join(' '); + if (fold(body).includes(needle)) { + best = Math.min(best, RANK_BODY); + } + } + + return best; + } + + private tasksOnly(entries: HelpEntry[]): HelpSearchResult[] { + return entries + .filter(entry => entry.kind === 'task') + .map(entry => ({ entry, prose: this.helpContent.prose(entry.id) })); + } +} +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. + +- [ ] **Step 5: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 6: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help/services +git commit -m "feat(help): add diacritic-folding help search with tasks ranked first" +``` + +--- + +### Task 5: tp-help-icon + +**Files:** +- Create: `help/components/help-icon/help-icon.component.ts` +- Create: `help/components/help-icon/help-icon.component.html` +- Create: `help/components/help-icon/help-icon.component.scss` +- Modify: `time-planning-pn.module.ts` (declare `HelpIconComponent`, import `OverlayModule`) +- Test: `help/components/help-icon/help-icon.component.spec.ts` + +**Interfaces:** +- Consumes: `HelpContentService` (Task 2), `HelpPanelService` is **not** used here — the "More" link emits an output instead, so this component stays independent of Task 7. +- Produces: `HelpIconComponent`, selector `tp-help-icon`, `@Input() helpId: HelpEntryId`, `@Output() openInPanel = new EventEmitter()`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpIconComponent } from './help-icon.component'; +import { enUS } from '../../i18n/enUS'; + +describe('HelpIconComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HelpIconComponent], + imports: [OverlayModule, NoopAnimationsModule, MatIconModule, MatButtonModule], + providers: [{ provide: TranslateService, useValue: { currentLang: 'en-US' } }], + }).compileComponents(); + + fixture = TestBed.createComponent(HelpIconComponent); + fixture.componentInstance.helpId = 'toolbar.dateRange'; + fixture.detectChanges(); + }); + + it('labels the button with the entry title', () => { + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button.getAttribute('aria-label')).toBe(enUS['toolbar.dateRange'].title); + }); + + it('starts closed and opens on click', () => { + expect(fixture.componentInstance.isOpen).toBe(false); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + expect(fixture.componentInstance.isOpen).toBe(true); + }); + + it('closes on Escape', () => { + fixture.componentInstance.isOpen = true; + fixture.componentInstance.onOverlayKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(fixture.componentInstance.isOpen).toBe(false); + }); + + it('ignores other keys', () => { + fixture.componentInstance.isOpen = true; + fixture.componentInstance.onOverlayKeydown(new KeyboardEvent('keydown', { key: 'a' })); + expect(fixture.componentInstance.isOpen).toBe(true); + }); + + it('emits the id when More is used, and closes', () => { + const seen: string[] = []; + fixture.componentInstance.openInPanel.subscribe(id => seen.push(id)); + fixture.componentInstance.isOpen = true; + fixture.componentInstance.onMore(); + expect(seen).toEqual(['toolbar.dateRange']); + expect(fixture.componentInstance.isOpen).toBe(false); + }); + + it('renders nothing for an unknown id rather than throwing', () => { + const other = TestBed.createComponent(HelpIconComponent); + other.componentInstance.helpId = 'nope' as never; + expect(() => other.detectChanges()).not.toThrow(); + expect(other.nativeElement.querySelector('button')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './help-icon.component'`. + +- [ ] **Step 3: Write the component class** + +```ts +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { HelpEntryId, HelpProse } from '../../help.model'; +import { HelpContentService } from '../../services/help-content.service'; + +@Component({ + selector: 'tp-help-icon', + templateUrl: './help-icon.component.html', + styleUrls: ['./help-icon.component.scss'], + standalone: false, +}) +export class HelpIconComponent { + @Input() helpId!: HelpEntryId; + @Output() openInPanel = new EventEmitter(); + + isOpen = false; + + readonly positions: ConnectedPosition[] = [ + { originX: 'center', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 6 }, + { originX: 'center', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -6 }, + { originX: 'center', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 6 }, + { originX: 'center', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -6 }, + ]; + + constructor(private helpContent: HelpContentService) {} + + get prose(): HelpProse | undefined { + return this.helpContent.entry(this.helpId) ? this.helpContent.prose(this.helpId) : undefined; + } + + toggle(): void { + this.isOpen = !this.isOpen; + } + + close(): void { + this.isOpen = false; + } + + onOverlayKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.close(); + } + } + + onMore(): void { + this.openInPanel.emit(this.helpId); + this.close(); + } +} +``` + +- [ ] **Step 4: Write the template** + +`cdkConnectedOverlayUsePopover` is not available in CDK 20.2.14; this uses the standard connected overlay, which stacks correctly above an open `MatDialog` because CDK appends later overlays after the dialog pane in `.cdk-overlay-container`. + +```html + + + + + + + +``` + +The label comes from `HelpUiStrings.moreInHelp`; add a `get ui(): HelpUiStrings { return this.helpContent.ui(); }` accessor to the component. No key is added to the plugin's shared locale files. + +- [ ] **Step 5: Write the SCSS** + +```scss +.tp-help-icon { + width: 20px; + height: 20px; + line-height: 20px; + vertical-align: middle; + + .mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: var(--text-body, #7f868d); + } + + &:hover .mat-icon { + color: var(--primary, #289694); + } +} + +.tp-help-popover { + max-width: 320px; + padding: 12px 14px; + border: 1px solid var(--border, #e2e6e9); + border-radius: 8px; + background: var(--bg, #ffffff); + box-shadow: 0 2px 6px rgba(15, 19, 22, 0.1), 0 12px 32px rgba(15, 19, 22, 0.16); + + &__title { + margin: 0 0 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-header, #0f1316); + } + + &__body { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--text-body, #7f868d); + } + + &__more { + margin-top: 9px; + padding: 0; + border: 0; + background: none; + font-size: 12px; + font-weight: 500; + color: var(--primary, #289694); + cursor: pointer; + } +} +``` + +- [ ] **Step 6: Declare in the module** + +In `time-planning-pn.module.ts`: add `OverlayModule` from `@angular/cdk/overlay` to `imports`, and `HelpIconComponent` to `declarations`. + +- [ ] **Step 7: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. + +- [ ] **Step 8: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 9: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help \ + eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts +git commit -m "feat(help): add tp-help-icon popover built on cdkConnectedOverlay" +``` + +--- + +### Task 6: tp-help-hint + +**Files:** +- Create: `help/components/help-hint/help-hint.component.ts` +- Create: `help/components/help-hint/help-hint.component.html` +- Create: `help/components/help-hint/help-hint.component.scss` +- Modify: `time-planning-pn.module.ts` +- Test: `help/components/help-hint/help-hint.component.spec.ts` + +**Interfaces:** +- Consumes: `HelpContentService`. +- Produces: `HelpHintComponent`, selector `tp-help-hint`, `@Input() helpId: HelpEntryId`, `@Input() tone: 'info' | 'warn' = 'info'`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpHintComponent } from './help-hint.component'; +import { enUS } from '../../i18n/enUS'; + +describe('HelpHintComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HelpHintComponent], + imports: [MatIconModule], + providers: [{ provide: TranslateService, useValue: { currentLang: 'en-US' } }], + }).compileComponents(); + fixture = TestBed.createComponent(HelpHintComponent); + }); + + it('renders the entry short text', () => { + fixture.componentInstance.helpId = 'dayCell.futureDisabled'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(enUS['dayCell.futureDisabled'].short); + }); + + it('uses the info tone by default and warn when asked', () => { + fixture.componentInstance.helpId = 'dayCell.futureDisabled'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.help-text')?.classList).not.toContain('help-text--warn'); + + fixture.componentInstance.tone = 'warn'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.help-text')?.classList).toContain('help-text--warn'); + }); + + it('renders nothing for an unknown id', () => { + fixture.componentInstance.helpId = 'nope' as never; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.help-text')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './help-hint.component'`. + +- [ ] **Step 3: Write the component** + +```ts +import { Component, Input } from '@angular/core'; +import { HelpEntryId, HelpProse } from '../../help.model'; +import { HelpContentService } from '../../services/help-content.service'; + +@Component({ + selector: 'tp-help-hint', + templateUrl: './help-hint.component.html', + styleUrls: ['./help-hint.component.scss'], + standalone: false, +}) +export class HelpHintComponent { + @Input() helpId!: HelpEntryId; + @Input() tone: 'info' | 'warn' = 'info'; + + constructor(private helpContent: HelpContentService) {} + + get prose(): HelpProse | undefined { + return this.helpContent.entry(this.helpId) ? this.helpContent.prose(this.helpId) : undefined; + } +} +``` + +- [ ] **Step 4: Write the template** + +This reuses the plugin's existing `.help-text` + `mat-icon>info` pattern. It appears in exactly two places today — `pay-day-rule-form.component.html:105-108` and `day-type-rule-dialog.component.html:161` — so this component both reuses and standardises it. + +```html +
+ {{ tone === 'warn' ? 'warning' : 'info' }} + {{ helpProse.short }} +
+``` + +- [ ] **Step 5: Write the SCSS** + +```scss +.help-text { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 9px 11px; + border-left: 3px solid var(--primary, #289694); + border-radius: 0 5px 5px 0; + background: var(--primary-light, #f5fcfc); + font-size: 12.5px; + line-height: 1.5; + color: var(--text-body, #7f868d); + + .mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--primary, #289694); + } + + &--warn { + border-left-color: var(--warning, #e2a01c); + background: rgba(226, 160, 28, 0.12); + + .mat-icon { + color: var(--warning, #e2a01c); + } + } +} +``` + +- [ ] **Step 6: Declare `HelpHintComponent` in `time-planning-pn.module.ts`** + +- [ ] **Step 7: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. + +- [ ] **Step 8: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 9: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help \ + eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts +git commit -m "feat(help): add tp-help-hint inline hint component" +``` + +--- + +### Task 7: HelpPanelService and tp-help-panel + +**Files:** +- Create: `help/services/help-panel.service.ts` +- Create: `help/components/help-panel/help-panel.component.ts` +- Create: `help/components/help-panel/help-panel.component.html` +- Create: `help/components/help-panel/help-panel.component.scss` +- Modify: `time-planning-pn.module.ts` +- Test: `help/services/help-panel.service.spec.ts` +- Test: `help/components/help-panel/help-panel.component.spec.ts` + +**Interfaces:** +- Consumes: `HelpContentService` (Task 2), `HelpSearchService` + `HelpSearchResult` (Task 4). +- Produces: `HelpPanelService` with `isOpen$: Observable`, `target$: Observable`, `open(target?: HelpEntryId): void`, `close(): void`; and `HelpPanelComponent`, selector `tp-help-panel`, `@Input() isAdmin = false`. + +- [ ] **Step 1: Write the failing service test** + +```ts +import { HelpPanelService } from './help-panel.service'; +import { firstValueFrom } from 'rxjs'; + +describe('HelpPanelService', () => { + it('starts closed', async () => { + const service = new HelpPanelService(); + expect(await firstValueFrom(service.isOpen$)).toBe(false); + }); + + it('opens with no target', async () => { + const service = new HelpPanelService(); + service.open(); + expect(await firstValueFrom(service.isOpen$)).toBe(true); + expect(await firstValueFrom(service.target$)).toBeNull(); + }); + + it('opens on a target and clears it on close', async () => { + const service = new HelpPanelService(); + service.open('flex.sumFlex'); + expect(await firstValueFrom(service.target$)).toBe('flex.sumFlex'); + service.close(); + expect(await firstValueFrom(service.isOpen$)).toBe(false); + expect(await firstValueFrom(service.target$)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Write the failing component test** + +```ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpPanelComponent } from './help-panel.component'; +import { HelpPanelService } from '../../services/help-panel.service'; + +describe('HelpPanelComponent', () => { + let fixture: ComponentFixture; + let panel: HelpPanelService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HelpPanelComponent], + imports: [FormsModule, MatIconModule, MatButtonModule], + providers: [ + HelpPanelService, + { provide: TranslateService, useValue: { currentLang: 'en-US' } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(HelpPanelComponent); + panel = TestBed.inject(HelpPanelService); + fixture.detectChanges(); + }); + + it('renders nothing while closed', () => { + expect(fixture.nativeElement.querySelector('.tp-help-panel')).toBeNull(); + }); + + it('browses grouped sections when open with no query', () => { + panel.open(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.tp-help-panel')).not.toBeNull(); + expect(fixture.componentInstance.isSearching).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Date range'); + }); + + it('switches to results when a query is typed', () => { + panel.open(); + fixture.componentInstance.onQueryChange('vacation'); + fixture.detectChanges(); + expect(fixture.componentInstance.isSearching).toBe(true); + expect(fixture.componentInstance.results.length).toBeGreaterThan(0); + }); + + it('hides admin-only entries when isAdmin is false', () => { + fixture.componentInstance.isAdmin = false; + panel.open(); + fixture.detectChanges(); + const ids = fixture.componentInstance.sections + .flatMap(section => section.entries.map(entry => entry.id)); + expect(ids).not.toContain('toolbar.payrollExport'); + }); + + it('marks the deep-link target', () => { + panel.open('flex.sumFlex'); + fixture.detectChanges(); + expect(fixture.componentInstance.targetId).toBe('flex.sumFlex'); + }); + + it('closes through the service', () => { + panel.open(); + fixture.detectChanges(); + fixture.componentInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.tp-help-panel')).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run both and confirm they fail** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — modules not found. + +- [ ] **Step 4: Implement `HelpPanelService`** + +```ts +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { HelpEntryId } from '../help.model'; + +@Injectable({ providedIn: 'root' }) +export class HelpPanelService { + private readonly openState = new BehaviorSubject(false); + private readonly targetState = new BehaviorSubject(null); + + readonly isOpen$: Observable = this.openState.asObservable(); + readonly target$: Observable = this.targetState.asObservable(); + + open(target?: HelpEntryId): void { + this.targetState.next(target ?? null); + this.openState.next(true); + } + + close(): void { + this.openState.next(false); + this.targetState.next(null); + } +} +``` + +- [ ] **Step 5: Implement `HelpPanelComponent`** + +```ts +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { HelpEntry, HelpEntryId, HelpProse, HelpSection, HelpUiStrings } from '../../help.model'; +import { HelpContentService } from '../../services/help-content.service'; +import { HelpPanelService } from '../../services/help-panel.service'; +import { HelpSearchResult, HelpSearchService } from '../../services/help-search.service'; +import { HelpTourService } from '../../services/help-tour.service'; + +interface PanelSection { + section: HelpSection; + entries: HelpEntry[]; +} + +const SECTION_ORDER: HelpSection[] = ['task', 'toolbar', 'grid', 'dayCell', 'flex']; + +@Component({ + selector: 'tp-help-panel', + templateUrl: './help-panel.component.html', + styleUrls: ['./help-panel.component.scss'], + standalone: false, +}) +export class HelpPanelComponent implements OnInit, OnDestroy { + @Input() isAdmin = false; + + isOpen = false; + targetId: HelpEntryId | null = null; + query = ''; + results: HelpSearchResult[] = []; + sections: PanelSection[] = []; + expanded: HelpEntryId | null = null; + + private readonly subscriptions = new Subscription(); + + constructor( + private helpContent: HelpContentService, + private helpSearch: HelpSearchService, + private helpPanel: HelpPanelService, + private helpTour: HelpTourService, + ) {} + + get ui(): HelpUiStrings { + return this.helpContent.ui(); + } + + get isSearching(): boolean { + return this.query.trim().length > 0; + } + + ngOnInit(): void { + this.subscriptions.add(this.helpPanel.isOpen$.subscribe(isOpen => { + this.isOpen = isOpen; + if (isOpen) { + this.buildSections(); + } else { + this.query = ''; + this.results = []; + } + })); + this.subscriptions.add(this.helpPanel.target$.subscribe(target => { + this.targetId = target; + this.expanded = target; + })); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + onQueryChange(query: string): void { + this.query = query; + this.results = this.helpSearch.search(query, { isAdmin: this.isAdmin }); + } + + clearQuery(): void { + this.onQueryChange(''); + } + + toggleEntry(id: HelpEntryId): void { + this.expanded = this.expanded === id ? null : id; + } + + prose(id: HelpEntryId): HelpProse { + return this.helpContent.prose(id); + } + + sectionLabel(section: HelpSection): string { + const labels: Record = { + task: this.ui.sectionTask, + toolbar: this.ui.sectionToolbar, + grid: this.ui.sectionGrid, + dayCell: this.ui.sectionDayCell, + flex: this.ui.sectionFlex, + }; + return labels[section]; + } + + close(): void { + this.helpPanel.close(); + } + + /** Replays the page tour. Closes the panel first so the anchors are visible. */ + replayTour(): void { + this.helpPanel.close(); + setTimeout(() => this.helpTour.start('page', { isAdmin: this.isAdmin })); + } + + private buildSections(): void { + const entries = this.helpContent.entries({ isAdmin: this.isAdmin }); + this.sections = SECTION_ORDER + .map(section => ({ section, entries: entries.filter(entry => entry.section === section) })) + .filter(group => group.entries.length > 0); + } +} +``` + +- [ ] **Step 6: Write the template** + +```html + +``` + +All chrome labels come from `HelpUiStrings` (Task 1), never the `translate` pipe — the plugin's 25 shared locale files stay untouched. + +- [ ] **Step 7: Write the SCSS** + +```scss +.tp-help-panel { + position: fixed; + top: 0; + right: 0; + z-index: 900; + display: flex; + flex-direction: column; + width: 340px; + max-width: 100vw; + height: 100vh; + border-left: 1px solid var(--border, #e2e6e9); + background: var(--bg, #ffffff); + box-shadow: -8px 0 24px rgba(15, 19, 22, 0.08); + + &__top { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 15px; + border-bottom: 1px solid var(--border, #e2e6e9); + + h5 { + margin: 0; + font-size: 14px; + font-weight: 600; + } + } + + &__spacer { flex: 1; } + + &__search { + display: flex; + align-items: center; + gap: 8px; + margin: 11px 15px; + padding: 8px 13px; + border: 1px solid var(--border, #e2e6e9); + border-radius: 19px; + + input { + flex: 1; + border: 0; + background: none; + font-size: 13px; + color: var(--text-header, #0f1316); + + &:focus { outline: none; } + } + + button { + border: 0; + background: none; + cursor: pointer; + } + } + + &__body { + flex: 1; + overflow-y: auto; + padding-bottom: 16px; + } + + &__section { + margin: 14px 0 5px; + padding: 0 15px; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--primary, #289694); + } + + &__count { + margin: 10px 15px 2px; + font-size: 11px; + color: var(--text-body, #7f868d); + } +} + +.tp-help-entry { + padding: 7px 15px 9px; + border-left: 2px solid transparent; + + &--target { + border-left-color: var(--primary, #289694); + background: var(--primary-light, #f5fcfc); + } + + &__head { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + padding: 0; + border: 0; + background: none; + text-align: left; + cursor: pointer; + + b { + font-size: 12.5px; + font-weight: 500; + color: var(--text-header, #0f1316); + } + } + + &__kind { + flex: none; + padding: 0 4px; + border: 1px solid var(--border, #e2e6e9); + border-radius: 3px; + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-body, #7f868d); + + &--task { + border-color: var(--primary, #289694); + color: var(--primary, #289694); + } + } + + p { + margin: 2px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-body, #7f868d); + } + + &__steps { + margin: 8px 0 0; + padding-left: 17px; + + li { + margin-bottom: 3px; + font-size: 12px; + line-height: 1.5; + color: var(--text-header, #0f1316); + } + } +} +``` + +- [ ] **Step 8: Declare `HelpPanelComponent` in `time-planning-pn.module.ts`** and ensure `FormsModule` is imported there. + +- [ ] **Step 9: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. + +- [ ] **Step 10: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 11: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help \ + eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/i18n +git commit -m "feat(help): add searchable help side panel" +``` + +--- + +### Task 8: HelpTourService and tp-help-tour + +**Files:** +- Create: `help/services/help-tour.service.ts` +- Create: `help/components/help-tour/help-tour.component.ts` +- Create: `help/components/help-tour/help-tour.component.html` +- Create: `help/components/help-tour/help-tour.component.scss` +- Modify: `time-planning-pn.module.ts` +- Test: `help/services/help-tour.service.spec.ts` +- Test: `help/components/help-tour/help-tour.component.spec.ts` + +**Interfaces:** +- Consumes: `HelpContentService.tourEntries` (Task 2). +- Produces: `HelpTourService` with `state$: Observable`, `start(tour: HelpTourName, opts: { isAdmin: boolean }): void`, `next(): void`, `stop(): void`, `hasSeen(tour: HelpTourName): boolean`, `markSeen(tour: HelpTourName): void`; the exported interface `HelpTourState { entry: HelpEntry; index: number; total: number; }`; the storage key constant `TOUR_STORAGE_KEY = 'tp.planning.tour.v1'`; and `HelpTourComponent`, selector `tp-help-tour`. + +- [ ] **Step 1: Write the failing test** + +The tour must skip a step whose anchor is not in the DOM — that is the behaviour that keeps it working for a non-admin, and when the worker select is hidden because only one site exists. + +```ts +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; +import { HelpTourService, TOUR_STORAGE_KEY } from './help-tour.service'; +import { HelpContentService } from './help-content.service'; + +describe('HelpTourService', () => { + let service: HelpTourService; + + const anchor = (id: string) => { + const element = document.createElement('div'); + element.setAttribute('data-tp-help', id); + document.body.appendChild(element); + }; + + beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + HelpTourService, + HelpContentService, + { provide: TranslateService, useValue: { currentLang: 'en-US' } }, + ], + }); + service = TestBed.inject(HelpTourService); + }); + + it('is idle before it starts', async () => { + expect(await firstValueFrom(service.state$)).toBeNull(); + }); + + it('starts on the first step whose anchor exists', async () => { + anchor('grid.dayCellAnatomy'); + service.start('page', { isAdmin: false }); + const state = await firstValueFrom(service.state$); + expect(state?.entry.id).toBe('grid.dayCellAnatomy'); + expect(state?.index).toBe(0); + expect(state?.total).toBe(1); + }); + + it('skips steps with no anchor in the DOM', async () => { + anchor('toolbar.dateRange'); + anchor('grid.openDay'); + service.start('page', { isAdmin: false }); + let state = await firstValueFrom(service.state$); + expect(state?.entry.id).toBe('toolbar.dateRange'); + expect(state?.total).toBe(2); + service.next(); + state = await firstValueFrom(service.state$); + expect(state?.entry.id).toBe('grid.openDay'); + }); + + it('never offers the payroll step to a non-admin even when its anchor exists', async () => { + anchor('toolbar.payrollExport'); + anchor('toolbar.dateRange'); + service.start('page', { isAdmin: false }); + const state = await firstValueFrom(service.state$); + expect(state?.total).toBe(1); + expect(state?.entry.id).toBe('toolbar.dateRange'); + }); + + it('ends after the last step', async () => { + anchor('toolbar.dateRange'); + service.start('page', { isAdmin: false }); + service.next(); + expect(await firstValueFrom(service.state$)).toBeNull(); + }); + + it('does not start when no anchor is present', async () => { + service.start('page', { isAdmin: false }); + expect(await firstValueFrom(service.state$)).toBeNull(); + }); + + it('does not mark a tour seen merely by being subscribed to', async () => { + // Regression guard: state$ replays null to every new subscriber. + await firstValueFrom(service.state$); + expect(service.hasSeen('page')).toBe(false); + }); + + it('marks the tour seen once it runs to the end', () => { + anchor('toolbar.dateRange'); + service.start('page', { isAdmin: false }); + expect(service.hasSeen('page')).toBe(false); + service.next(); + expect(service.hasSeen('page')).toBe(true); + }); + + it('marks the tour seen when it is skipped', () => { + anchor('toolbar.dateRange'); + service.start('page', { isAdmin: false }); + service.stop(); + expect(service.hasSeen('page')).toBe(true); + }); + + it('does not mark a tour seen when it could not start for lack of anchors', () => { + service.start('page', { isAdmin: false }); + expect(service.hasSeen('page')).toBe(false); + }); + + it('records that a tour has been seen', () => { + expect(service.hasSeen('page')).toBe(false); + service.markSeen('page'); + expect(service.hasSeen('page')).toBe(true); + expect(localStorage.getItem(TOUR_STORAGE_KEY)).toContain('page'); + }); + + it('survives localStorage being unavailable', () => { + const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(service.hasSeen('page')).toBe(false); + getItem.mockRestore(); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — `Cannot find module './help-tour.service'`. + +- [ ] **Step 3: Implement `HelpTourService`** + +```ts +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { HelpEntry, HelpTourName } from '../help.model'; +import { HelpContentService } from './help-content.service'; + +export const TOUR_STORAGE_KEY = 'tp.planning.tour.v1'; + +export interface HelpTourState { + entry: HelpEntry; + index: number; + total: number; +} + +@Injectable({ providedIn: 'root' }) +export class HelpTourService { + private readonly stateSubject = new BehaviorSubject(null); + private steps: HelpEntry[] = []; + private index = 0; + private current: HelpTourName | null = null; + + readonly state$: Observable = this.stateSubject.asObservable(); + + constructor(private helpContent: HelpContentService) {} + + /** Steps whose anchor is absent are dropped, never treated as an error. */ + start(tour: HelpTourName, opts: { isAdmin: boolean }): void { + this.current = tour; + this.steps = this.helpContent + .tourEntries(tour, opts) + .filter(entry => !!this.anchorElement(entry)); + this.index = 0; + this.emit(); + } + + next(): void { + this.index += 1; + this.emit(); + } + + /** Skipping counts as having seen it — but only if a step was actually shown. */ + stop(): void { + if (this.current && this.steps.length > 0) { + this.markSeen(this.current); + } + this.current = null; + this.steps = []; + this.index = 0; + this.stateSubject.next(null); + } + + /** True while a tour is on screen. */ + get isRunning(): boolean { + return this.stateSubject.value !== null; + } + + anchorElement(entry: HelpEntry): HTMLElement | null { + return entry.anchor + ? document.querySelector(`[data-tp-help="${entry.anchor}"]`) + : null; + } + + hasSeen(tour: HelpTourName): boolean { + return this.readSeen().includes(tour); + } + + markSeen(tour: HelpTourName): void { + const seen = this.readSeen(); + if (!seen.includes(tour)) { + this.writeSeen([...seen, tour]); + } + } + + private emit(): void { + const entry = this.steps[this.index]; + if (entry) { + this.stateSubject.next({ entry, index: this.index, total: this.steps.length }); + return; + } + // Ran to the end. Record it here, in the service, rather than in the component: + // state$ is a BehaviorSubject seeded null, so a component that marks "seen" + // whenever it observes null would do so on its very first subscription — before + // any tour has run — and the automatic first-run tour would never appear. + // `steps.length` guards the other direction: a tour that could not start because + // none of its anchors were in the DOM has not been seen, and must be offered again. + if (this.current && this.steps.length > 0) { + this.markSeen(this.current); + } + this.current = null; + this.stateSubject.next(null); + } + + private readSeen(): string[] { + try { + return JSON.parse(localStorage.getItem(TOUR_STORAGE_KEY) ?? '[]') as string[]; + } catch { + return []; + } + } + + private writeSeen(seen: string[]): void { + try { + localStorage.setItem(TOUR_STORAGE_KEY, JSON.stringify(seen)); + } catch { + // Storage unavailable (private mode, blocked cookies) — the tour simply reruns. + } + } +} +``` + +- [ ] **Step 4: Implement `HelpTourComponent`** + +```ts +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { Subscription } from 'rxjs'; +import { HelpProse, HelpTourName } from '../../help.model'; +import { HelpContentService } from '../../services/help-content.service'; +import { HelpTourService, HelpTourState } from '../../services/help-tour.service'; + +@Component({ + selector: 'tp-help-tour', + templateUrl: './help-tour.component.html', + styleUrls: ['./help-tour.component.scss'], + standalone: false, +}) +export class HelpTourComponent implements OnInit, OnDestroy { + @Input() tour: HelpTourName = 'page'; + @Input() isAdmin = false; + + state: HelpTourState | null = null; + origin: HTMLElement | null = null; + + readonly positions: ConnectedPosition[] = [ + { originX: 'center', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 10 }, + { originX: 'center', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -10 }, + ]; + + private readonly subscriptions = new Subscription(); + + constructor( + private helpContent: HelpContentService, + private helpTour: HelpTourService, + ) {} + + get prose(): HelpProse | null { + return this.state ? this.helpContent.prose(this.state.entry.id) : null; + } + + ngOnInit(): void { + // Deliberately does NOT mark the tour seen here. state$ is a BehaviorSubject + // seeded null, so this fires once at mount with state === null; marking seen + // there would suppress the automatic first run. The service records it instead, + // when a tour actually ends or is skipped. + this.subscriptions.add(this.helpTour.state$.subscribe(state => { + this.state = state; + this.origin = state ? this.helpTour.anchorElement(state.entry) : null; + })); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + next(): void { + this.helpTour.next(); + } + + skip(): void { + this.helpTour.stop(); + } +} +``` + +- [ ] **Step 5: Write the template** + +```html + + + +``` + +Labels come from `HelpUiStrings`; add `get ui(): HelpUiStrings { return this.helpContent.ui(); }` to `HelpTourComponent`. No key is added to the plugin's shared locale files. + +- [ ] **Step 6: Write the SCSS** + +```scss +.tp-help-tour { + width: 320px; + padding: 15px 16px 13px; + border: 1px solid var(--border, #e2e6e9); + border-radius: 9px; + background: var(--bg, #ffffff); + box-shadow: 0 2px 6px rgba(15, 19, 22, 0.1), 0 12px 32px rgba(15, 19, 22, 0.16); + + h5 { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + } + + &__step { + margin: 0 0 7px; + font-size: 10.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--primary, #289694); + } + + &__body { + margin: 0 0 13px; + font-size: 12.5px; + line-height: 1.5; + color: var(--text-body, #7f868d); + } + + &__actions { + display: flex; + gap: 9px; + align-items: center; + } + + &__skip, + &__next { + padding: 7px 15px; + border: 1px solid var(--primary, #289694); + border-radius: 18px; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + } + + &__skip { + background: transparent; + color: var(--primary, #289694); + } + + &__next { + background: var(--primary, #289694); + color: #ffffff; + } +} +``` + +- [ ] **Step 6b: Write the component regression spec** + +The bug this guards against is subtle and silent: a component that marks the tour seen +whenever it observes a null state does so at mount, because `state$` is a +`BehaviorSubject` seeded null — and the automatic first run then never happens. + +```ts +import { TestBed } from '@angular/core/testing'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpTourComponent } from './help-tour.component'; +import { HelpTourService } from '../../services/help-tour.service'; + +describe('HelpTourComponent', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [HelpTourComponent], + imports: [OverlayModule], + providers: [{ provide: TranslateService, useValue: { currentLang: 'en-US' } }], + }); + }); + + it('does not mark the tour seen just by being mounted', () => { + const fixture = TestBed.createComponent(HelpTourComponent); + fixture.componentInstance.tour = 'page'; + fixture.detectChanges(); + expect(TestBed.inject(HelpTourService).hasSeen('page')).toBe(false); + }); + + it('shows no card while no tour is running', () => { + const fixture = TestBed.createComponent(HelpTourComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.state).toBeNull(); + }); +}); +``` + +- [ ] **Step 7: Declare `HelpTourComponent` in `time-planning-pn.module.ts`** + +- [ ] **Step 8: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: PASS. + +- [ ] **Step 9: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 10: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help \ + eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/i18n +git commit -m "feat(help): add guided tour that skips steps with no anchor" +``` + +--- + +### Task 9: Wire the surfaces into the page + +**Files:** +- Modify: `components/plannings/time-plannings-container/time-plannings-container.component.html` (toolbar `?` button after the `div.line-vert` at :78; `data-tp-help` on the toolbar controls; mount `tp-help-panel` and `tp-help-tour`) +- Modify: `components/plannings/time-plannings-container/time-plannings-container.component.ts` (open panel, start page tour once) +- Modify: `components/plannings/time-plannings-table/time-plannings-table.component.html` (`data-tp-help` anchors, `tp-help-hint` under the grid) +- Modify: `components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html` (`tp-help-icon` on the field groups, `data-tp-help` anchors, `tp-help-hint` for the future-date case, `tp-help-tour` for the dialog tour) +- Modify: `components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts` (start the dialog tour once) +- Test: `help/help-wiring.spec.ts` + +**Interfaces:** +- Consumes: everything from Tasks 5-8. +- Produces: no new exports. This task is additive markup plus two small container methods. + +- [ ] **Step 1: Write the failing wiring test** + +This is the test that stops the anchors rotting. It reads the templates off disk and asserts each side of the contract. + +```ts +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { PLANNING_HELP_ENTRIES } from './planning-help.registry'; + +const MODULE_ROOT = join(__dirname, '..'); + +const TEMPLATES = [ + 'components/plannings/time-plannings-container/time-plannings-container.component.html', + 'components/plannings/time-plannings-table/time-plannings-table.component.html', + 'components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html', +].map(relative => readFileSync(join(MODULE_ROOT, relative), 'utf8')); + +const MARKUP = TEMPLATES.join('\n'); + +describe('help wiring', () => { + it('anchors every entry that a tour needs', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.tourStep !== undefined)) { + expect(MARKUP).toContain(`data-tp-help="${entry.anchor}"`); + } + }); + + it('only uses helpIds that exist in the registry', () => { + const known = new Set(PLANNING_HELP_ENTRIES.map(e => e.id)); + const used = [...MARKUP.matchAll(/helpId="([^"]+)"/g)].map(match => match[1]); + expect(used.length).toBeGreaterThan(0); + for (const id of used) { + expect(known.has(id as never)).toBe(true); + } + }); + + it('only uses anchors that exist in the registry', () => { + const known = new Set(PLANNING_HELP_ENTRIES.map(e => e.anchor).filter(Boolean)); + const used = [...MARKUP.matchAll(/data-tp-help="([^"]+)"/g)].map(match => match[1]); + for (const anchor of used) { + expect(known.has(anchor)).toBe(true); + } + }); + + it('mounts the panel and both tours exactly once', () => { + expect((MARKUP.match(/ { + for (const id of ['flex.whatIsFlex', 'flex.sumFlex', 'flex.paidOutFlexRelation']) { + expect(MARKUP).toContain(`data-tp-help="${id}"`); + } + }); + + it('starts each tour from a component, since mounting alone does not', () => { + const containerTs = readFileSync(join(MODULE_ROOT, + 'components/plannings/time-plannings-container/time-plannings-container.component.ts'), 'utf8'); + const dialogTs = readFileSync(join(MODULE_ROOT, + 'components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts'), 'utf8'); + expect(containerTs).toMatch(/start\(\s*'page'/); + expect(dialogTs).toMatch(/start\(\s*'dialog'/); + }); + + it('never uses the translate pipe inside help templates', () => { + const helpTemplates = [ + 'help/components/help-panel/help-panel.component.html', + 'help/components/help-icon/help-icon.component.html', + 'help/components/help-tour/help-tour.component.html', + 'help/components/help-hint/help-hint.component.html', + ].map(relative => readFileSync(join(MODULE_ROOT, relative), 'utf8')).join('\n'); + expect(helpTemplates).not.toContain('| translate'); + }); + + it('does not introduce new translate keys for help chrome', () => { + // The help button's tooltip must come from HelpUiStrings, not a new shared key. + expect(MARKUP).not.toContain("'Help' | translate"); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn/help` +Expected: FAIL — no `data-tp-help` attributes exist yet. + +- [ ] **Step 3: Add the `?` button and mounts to the container template** + +After the last `div.line-vert` (currently line 78), as a sibling of the existing icon buttons: + +```html + +``` + +Add `data-tp-help` to the toolbar controls that carry an anchor: `toolbar.showResigned`, `toolbar.navBackward`, `toolbar.navForward`, `toolbar.workerFilter`, `toolbar.tagFilter`, `toolbar.dateRange`, `toolbar.downloadExcel`, `toolbar.payrollExport`, `toolbar.reload`. + +At the end of the container template, outside `eform-new-subheader`: + +```html + + +``` + +- [ ] **Step 4: Add the two container methods** + +In `time-plannings-container.component.ts`, injecting `HelpContentService`, `HelpPanelService` and `HelpTourService`: + +```ts +get helpUi(): HelpUiStrings { + return this.helpContent.ui(); +} + +openHelp(): void { + this.helpPanel.open(); +} + +private startTourOnce(): void { + if (!this.helpTour.hasSeen('page')) { + setTimeout(() => this.helpTour.start('page', { isAdmin: this.isAdmin })); + } +} +``` + +Call `startTourOnce()` at the end of the existing plannings-loaded handler, so the grid is rendered and the anchors exist. The `setTimeout` lets the current change-detection pass finish before the DOM is queried. + +- [ ] **Step 5: Add anchors and the hint to the table template** + +`data-tp-help` on: the pinned name cell (`grid.nameColumn`), the tag chips (`grid.tagChips`), the settings strip (`grid.settingsStrip`), a day cell (`grid.dayCellAnatomy` and `grid.openDay`), the weekly total (`grid.weeklyPlannedHours`), the message icons (`grid.messageIcons`), the name column header (`grid.sortName`). + +Under the grid: + +```html + +``` + +- [ ] **Step 6: Add icons, anchors, hints and the dialog tour to the workday dialog** + +`tp-help-icon` beside the Planned group (`dayCell.plannedTimes`), the Registered group (`dayCell.actualTimes`), the registered pause (`dayCell.resetPauseToRecorded`), Plan hours (`dayCell.planHours`), Netto override (`dayCell.nettoOverride`), Paid-out flex (`dayCell.paidOutFlex`), the day-type checkboxes (`dayCell.flags`), **the Save button (`dayCell.save`)**, the version-history button (`dayCell.versionHistory`), the shift rows (`dayCell.shiftCount`), a per-field reset (`dayCell.resetField`), the GPS button (`dayCell.gps`), the snapshot button (`dayCell.snapshot`), and the timepickers (`dayCell.oneMinuteIntervals`). Matching `data-tp-help` on each. + +`dayCell.save` is not optional: it is `tourStep: 6` of the dialog tour, and Step 1's wiring test asserts an anchor exists for every entry carrying a `tourStep`. + +The three flex entries get icons and anchors too, next to the figures they explain — `flex.whatIsFlex` and `flex.paidOutFlexRelation` beside the paid-out-flex field in this dialog, and `flex.sumFlex` beside the flex sum. These are the numbers the spec calls out as most likely to mislead, so reaching them only through the panel would miss the point. + +Then, at the end of the dialog template: + +```html + + +``` + +The dialog tour passes `isAdmin="false"` because no dialog entry is admin-only; the input exists only to satisfy the shared component's signature. + +- [ ] **Step 6b: Start the dialog tour** + +Mounting `tp-help-tour` only subscribes to state — nothing starts a tour. Without this the dialog tour can never fire. In `workday-entity-dialog.component.ts`, injecting `HelpTourService`: + +```ts +private startDialogTourOnce(): void { + if (!this.helpTourService.hasSeen('dialog')) { + setTimeout(() => this.helpTourService.start('dialog', { isAdmin: false })); + } +} +``` + +Call it at the end of `ngOnInit`, after the form is built, so the anchors exist in the DOM. + +- [ ] **Step 7: Run the tests and confirm they pass** + +Run: `cd eform-angular-frontend/eform-client && npx jest --testPathPatterns=time-planning-pn` +Expected: PASS — the whole plugin suite, to prove nothing else regressed. + +- [ ] **Step 8: Verify the build compiles** + +Run: `cd eform-angular-frontend/eform-client && npx ng build --configuration development` +Expected: build succeeds. Template errors do not surface in Jest, so this step is required before the commit. + +- [ ] **Step 9: Pre-commit gate** — code-reviewer and code-simplifier in parallel; resolve findings. + +- [ ] **Step 10: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn +git commit -m "feat(help): wire help icon, panel, tours and hints into the planning page" +``` + +--- + +### Task 10: Ship + +- [ ] **Step 1: Push the branch** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git push -u origin feat/planning-help-system +``` + +- [ ] **Step 2: Open the PR toward `stable`** + +```bash +gh pr create --base stable --title "feat(help): searchable in-page help for the planning page" --body "..." +``` + +- [ ] **Step 3: Watch CI** + +```bash +gh pr checks --watch +``` + +The `angular-unit-test` job runs the new specs. If it fails, investigate — do not rewrite existing tests to make it pass. Only fix tests added by this plan. After any fix, re-run the pre-commit gate before committing. + +- [ ] **Step 4: Merge once green** + +--- + +## Self-Review + +**Spec coverage.** Content model → Task 1. Locale coverage and fallback → Tasks 2, 3. Search → Task 4. ⓘ icon → Task 5. Inline hint → Task 6. Side panel → Task 7. Tours and anchor-skipping → Task 8. Anchoring and template wiring → Task 9. Admin filtering → covered by tests in Tasks 2, 4, 7, 8. Copy rules → enforced by the banned-word tests in Tasks 1 and 3. Testing section → each task's tests. Out-of-scope items → Global Constraints. + +**Deviation from the spec, recorded deliberately.** The spec's `HelpSection` union listed `shifts` and `flags`; no entry uses them, so this plan omits both. The spec also described the ⓘ overlay as using `cdkConnectedOverlayUsePopover="inline"`; that input does not exist in CDK 20.2.14, and the spec has been corrected — this plan uses a standard `cdkConnectedOverlay`. + +**Fixed after review.** Both reviewers ran against the first draft of this plan; these are the changes their findings produced. + +- The local test loop did not work at all. Jest runs from the frontend repo and its `testMatch` is scoped to that `rootDir`, so specs written only in the plugin repo were never discovered — every "run it and confirm it fails" step would have reported `No tests found`. Task 0 now establishes the `--roots` invocation and the `node_modules` symlink it needs, both verified end to end with a throwaway Angular TestBed spec before this plan was finalised. +- The plan contradicted its own constraint by adding UI labels to the 25 shared locale files. Those labels are now `HelpUiStrings` in `help/i18n/`, and a wiring test asserts no help template uses the `translate` pipe. +- The dialog tour could never start — mounting `tp-help-tour` only subscribes. Task 9 Step 6b adds the trigger, the dialog `.ts` is now in Task 9's file list, and a wiring test asserts both tours are started from a component. +- "Replayable from the panel" was specified but never built. The panel now has a replay action. +- `HelpTourComponent` marked the tour seen at mount, because `state$` replays `null` to new subscribers — which would have suppressed the automatic first run for every genuine first-time user. Recording moved into the service, guarded so a tour that could not start is not marked seen, with four service tests and a component regression spec. +- `dayCell.save` was required by the dialog tour and by Task 9's own wiring test, but Step 6 never anchored it. The three `flex.*` entries were reachable only through the panel, despite being the numbers the spec calls most misleading. Both now anchored. +- The "ranks tasks above controls" test was vacuous when a query returned only tasks; it now asserts both groups are present first. +- The Danish banned-word regex missed `administratoren` and `administratorens`, the forms most likely to be written. Now `\badministrator\w*\b`. +- Three tests asserted prose text that Task 1 never mandates ("Date range", "future"), so a copywriting choice could fail them. They now compare against the content file. +- Corrected line citations and one overstatement: the `.help-text` pattern is used twice in this plugin, not ~8 times. + +**Type consistency.** `HelpEntryId`, `HelpEntry`, `HelpProse`, `HelpProseMap` are defined once in Task 1 and used unchanged thereafter. `HelpContentService.prose/entry/entries/tourEntries/localeProse` are consumed with those exact names in Tasks 4, 5, 6, 7, 8. `HelpSearchResult` is defined in Task 4 and consumed in Task 7. `HelpTourState` and `TOUR_STORAGE_KEY` are defined in Task 8 and used in its own component and spec. `HelpPanelService.open/close/isOpen$/target$` are defined in Task 7 and called from Task 9. + +**One deliberate content decision.** Tasks 1 and 3 each write 48 prose entries. The plan gives the complete id list, the enforced authoring rules, the machine-checked constraints, and fully worked exemplars of every shape (task with steps and detail, control with detail, control without). Writing the remaining entries is the content work itself, not a placeholder — and the registry spec fails until all 48 exist, so completeness is verified rather than assumed. diff --git a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md index f6cbcf33..4889f69b 100644 --- a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md +++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md @@ -12,7 +12,7 @@ netto hours, the difference between planned and actual times, what saving actual triggers — none of it is written down anywhere the user can reach. Customer support answers the same questions repeatedly. -The page carries 67 `matTooltip`s, but they are icon labels ("Download Excel", +The page carries 75 `matTooltip`s, but they are icon labels ("Download Excel", "Reload table"), not explanations. There is no help affordance of any kind — no `?` button, no popover, no tour — anywhere in either repo. @@ -27,12 +27,12 @@ once appears everywhere a user might look for it. ## What a non-admin can actually do The planning page is **not admin-gated**. Its route guard requires only the -`time_planning_plugin_access` claim (`time-planning-pn.routing.ts:18-24`). Inside the +`time_planning_plugin_access` claim (`time-planning-pn.routing.ts:19-24`). Inside the page, exactly two things are admin-only: | Control | Gate | |---|---| -| Export to payroll button | `time-plannings-container.component.html:87`, and server-side via `PayrollExportController.cs:12` | +| Export to payroll button | `time-plannings-container.component.html:88`, and server-side via `PayrollExportController.cs:12` | | Assigned-site dialog (click on worker name) | `time-plannings-table.component.ts:370-372`, **client-side only** — the endpoint it calls checks the `GetWorkingHours` claim, not the admin role | Everything else — filters, navigation, Excel download, reload, and the entire day-cell @@ -128,10 +128,21 @@ English. | Inline hint | `` | `short` | **ⓘ icon.** A small `mat-icon-button` whose `aria-label` comes from the entry. Opens a -CDK connected overlay using `cdkConnectedOverlayUsePopover="inline"`, which renders -into the browser top layer. This matters because roughly half the help lives inside a -`MatDialog`: a body-appended overlay would fight the dialog's own z-index and focus -trap. Dismissed on Escape, backdrop click, and scroll. +`cdkConnectedOverlay` anchored to the button via `cdkOverlayOrigin`, with a transparent +backdrop, a close-on-scroll strategy, and fallback positions. Dismissed on Escape +(`overlayKeydown`), backdrop click, and scroll. + +This has to work inside a `MatDialog`, because roughly half the help lives in one. It +does: CDK appends every overlay to the same `.cdk-overlay-container`, and an overlay +opened while a dialog is up is appended after the dialog pane, so it stacks above it. +The proof is already in these two dialogs — `AssignedSiteDialogComponent` and +`WorkdayEntityDialogComponent` between them host 23 overlay-based controls +(`ngx-material-timepicker`, `matDatepicker`, `mtx-select`) that open correctly today. + +Note for the implementer: `cdkConnectedOverlayUsePopover` (native top-layer rendering) +does **not** exist in the installed `@angular/cdk` **20.2.14** — it is a later addition. +Do not reach for it; the standard overlay container behaviour described above is what +this design relies on. **Search.** A field at the top of the panel, focused when the panel is opened from the `?` button. With no query the panel shows its normal grouped browse view; search is @@ -172,9 +183,11 @@ whose anchor is absent from the DOM is skipped, not treated as an error** — th required, because the worker select only renders when `availableSites.length > 1` and the payroll button only renders for admins. -**Inline hint.** Renders the pattern already used ~8 times in this plugin — a -`div.help-text` containing `mat-icon>info` and a span (see -`pay-day-rule-form.component.html:104-107`). Used where the page currently explains +**Inline hint.** Renders the plugin's existing `div.help-text` pattern — a +`mat-icon>info` beside a span. It is used in exactly two places today +(`pay-day-rule-form.component.html:105-108` and +`day-type-rule-dialog.component.html:161`), so this work both reuses and +standardises it. Used where the page currently explains nothing: - Fields disabled because the date is in the future @@ -227,7 +240,8 @@ this is the single most valuable thing the help system can say. The full flag set is `TimePlanningMessagesEnum`: `DayOff`, `Vacation`, `Sick`, `Course`, `LeaveOfAbsence`, `Children1stSick`, `Children2stSick`, `TimeOff`, `Maternity`, `VacationDayOff`, `Holiday`, `PregnancyLeave`. `Blank` and `Care` are -excluded from the UI (`:241-242`) and get no entries. +excluded from the UI (`:242`) and get no entries. Note that `Care` is not a member of +`TimePlanningMessagesEnum` at all — that half of the check is dead defensive code. ### `toolbar` — controls (9) From 25455d8cdbef3e096bd1fc2677707a63065e6225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:15:02 +0200 Subject: [PATCH 05/38] =?UTF-8?q?docs:=20fix=20plan=20ordering=20hazard=20?= =?UTF-8?q?=E2=80=94=20panel=20emits=20replay=20output=20instead=20of=20in?= =?UTF-8?q?jecting=20the=20tour=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-flight scan found Task 7 (panel) injecting HelpTourService, which Task 8 creates — it would not have compiled in task order. The panel now raises replayTourRequested and Task 9 binds it, which also keeps the panel independent of the tour. Task 5's openInPanel output was produced and never consumed, leaving the popover's More in help link inert. Task 9 now binds it, with a wiring test covering both bindings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../plans/2026-09-04-planning-help-system.md | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-09-04-planning-help-system.md b/docs/superpowers/plans/2026-09-04-planning-help-system.md index 228dc468..1994c6bc 100644 --- a/docs/superpowers/plans/2026-09-04-planning-help-system.md +++ b/docs/superpowers/plans/2026-09-04-planning-help-system.md @@ -1440,7 +1440,8 @@ git commit -m "feat(help): add tp-help-hint inline hint component" **Interfaces:** - Consumes: `HelpContentService` (Task 2), `HelpSearchService` + `HelpSearchResult` (Task 4). -- Produces: `HelpPanelService` with `isOpen$: Observable`, `target$: Observable`, `open(target?: HelpEntryId): void`, `close(): void`; and `HelpPanelComponent`, selector `tp-help-panel`, `@Input() isAdmin = false`. +- Produces: `HelpPanelService` with `isOpen$: Observable`, `target$: Observable`, `open(target?: HelpEntryId): void`, `close(): void`; and `HelpPanelComponent`, selector `tp-help-panel`, `@Input() isAdmin = false`, `@Output() replayTourRequested = new EventEmitter()`. +- **Does not** consume `HelpTourService` — that service does not exist until Task 8. The panel raises `replayTourRequested` and Task 9 wires it to the tour. - [ ] **Step 1: Write the failing service test** @@ -1582,13 +1583,12 @@ export class HelpPanelService { - [ ] **Step 5: Implement `HelpPanelComponent`** ```ts -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Subscription } from 'rxjs'; import { HelpEntry, HelpEntryId, HelpProse, HelpSection, HelpUiStrings } from '../../help.model'; import { HelpContentService } from '../../services/help-content.service'; import { HelpPanelService } from '../../services/help-panel.service'; import { HelpSearchResult, HelpSearchService } from '../../services/help-search.service'; -import { HelpTourService } from '../../services/help-tour.service'; interface PanelSection { section: HelpSection; @@ -1615,11 +1615,12 @@ export class HelpPanelComponent implements OnInit, OnDestroy { private readonly subscriptions = new Subscription(); + @Output() replayTourRequested = new EventEmitter(); + constructor( private helpContent: HelpContentService, private helpSearch: HelpSearchService, private helpPanel: HelpPanelService, - private helpTour: HelpTourService, ) {} get ui(): HelpUiStrings { @@ -1682,10 +1683,14 @@ export class HelpPanelComponent implements OnInit, OnDestroy { this.helpPanel.close(); } - /** Replays the page tour. Closes the panel first so the anchors are visible. */ + /** + * Asks the host to replay the tour. The panel deliberately does not depend on + * HelpTourService — it is built before the tour exists, and keeping the panel + * independent of it means neither has to know about the other. + */ replayTour(): void { this.helpPanel.close(); - setTimeout(() => this.helpTour.start('page', { isAdmin: this.isAdmin })); + this.replayTourRequested.emit(); } private buildSections(): void { @@ -2486,6 +2491,11 @@ describe('help wiring', () => { expect(helpTemplates).not.toContain('| translate'); }); + it('binds the help-icon and panel outputs, or they are inert', () => { + expect(MARKUP).toContain('(openInPanel)='); + expect(MARKUP).toContain('(replayTourRequested)='); + }); + it('does not introduce new translate keys for help chrome', () => { // The help button's tooltip must come from HelpUiStrings, not a new shared key. expect(MARKUP).not.toContain("'Help' | translate"); @@ -2518,10 +2528,17 @@ Add `data-tp-help` to the toolbar controls that carry an anchor: `toolbar.showRe At the end of the container template, outside `eform-new-subheader`: ```html - + ``` +Every `tp-help-icon` on the page binds its `openInPanel` output so the popover's +"More in help" link actually opens the panel on that entry: + +```html + +``` + - [ ] **Step 4: Add the two container methods** In `time-plannings-container.component.ts`, injecting `HelpContentService`, `HelpPanelService` and `HelpTourService`: @@ -2531,8 +2548,13 @@ get helpUi(): HelpUiStrings { return this.helpContent.ui(); } -openHelp(): void { - this.helpPanel.open(); +openHelp(target?: HelpEntryId): void { + this.helpPanel.open(target); +} + +replayPageTour(): void { + // The panel has already closed itself; let that settle before querying anchors. + setTimeout(() => this.helpTour.start('page', { isAdmin: this.isAdmin })); } private startTourOnce(): void { From aaf208e7b227e2460ac67286d67cd4c5ffafc19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:17:35 +0200 Subject: [PATCH 06/38] chore: ignore the local node_modules symlink used for running plugin tests --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 39a1a250..5c91ca15 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,4 @@ eform-client/.idea/ .claude .worktrees/ .superpowers/ +eform-client/node_modules From 80fbc977da3f0897e520e634b7fd3a4fc799fdb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:25:49 +0200 Subject: [PATCH 07/38] feat(help): add planning help registry, types and English content Adds the content foundation for the planning-page help system: the id union and entry/prose types, a 48-entry registry with anchors, tour steps and task cross-references, and complete English prose written from the actual toolbar, grid and day-dialog templates. The day-type entries carry the rule the UI hides: the flags render as checkboxes but are mutually exclusive, and ticking one rewrites the day's netto hours -- Day off and Vacation day off to zero, every other type to that day's planned hours. An integrity spec enforces completeness, unique ids and tour steps, resolvable related ids, and that no user-facing copy mentions administrators. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../time-planning-pn/help/help.model.ts | 75 ++++ .../time-planning-pn/help/i18n/enUS.ts | 389 ++++++++++++++++++ .../time-planning-pn/help/i18n/index.ts | 14 + .../help/planning-help.registry.spec.ts | 81 ++++ .../help/planning-help.registry.ts | 87 ++++ 5 files changed, 646 insertions(+) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts new file mode 100644 index 00000000..6c37f74d --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts @@ -0,0 +1,75 @@ +export const HELP_IDS = [ + // tasks + 'task.registerVacation', 'task.registerSickness', 'task.registerDayOff', + 'task.correctRegisteredTime', 'task.addMissingRegistration', 'task.addExtraShift', + 'task.changePlannedHours', 'task.payOutFlex', 'task.exportForPayroll', + 'task.whoChangedThis', 'task.whereWasThisRegistered', 'task.filterToOneTeam', + // toolbar controls + 'toolbar.showResigned', 'toolbar.navBackward', 'toolbar.navForward', + 'toolbar.workerFilter', 'toolbar.tagFilter', 'toolbar.dateRange', + 'toolbar.downloadExcel', 'toolbar.payrollExport', 'toolbar.reload', + // grid controls + 'grid.nameColumn', 'grid.tagChips', 'grid.settingsStrip', 'grid.dayCellAnatomy', + 'grid.weeklyPlannedHours', 'grid.messageIcons', 'grid.sortName', 'grid.openDay', + // day-cell dialog controls + 'dayCell.versionHistory', 'dayCell.plannedTimes', 'dayCell.actualTimes', + 'dayCell.shiftCount', 'dayCell.resetField', 'dayCell.resetPauseToRecorded', + 'dayCell.gps', 'dayCell.snapshot', 'dayCell.futureDisabled', 'dayCell.planHours', + 'dayCell.nettoOverride', 'dayCell.paidOutFlex', 'dayCell.flags', + 'dayCell.commentOffice', 'dayCell.save', 'dayCell.oneMinuteIntervals', + // flex controls + 'flex.whatIsFlex', 'flex.sumFlex', 'flex.paidOutFlexRelation', +] as const; + +export type HelpEntryId = typeof HELP_IDS[number]; +export type HelpKind = 'control' | 'task'; +export type HelpSection = 'task' | 'toolbar' | 'grid' | 'dayCell' | 'flex'; +export type HelpTourName = 'page' | 'dialog'; + +export interface HelpEntry { + id: HelpEntryId; + kind: HelpKind; + section: HelpSection; + /** data-tp-help value on the element this entry describes. Controls only. */ + anchor?: string; + tour?: HelpTourName; + tourStep?: number; + adminOnly?: boolean; + /** Tasks only: the controls this task touches. */ + related?: HelpEntryId[]; +} + +export interface HelpProse { + title: string; + short: string; + detail?: string; + /** Tasks only, in order. */ + steps?: string[]; + /** Search synonyms, in this locale's language. */ + keywords: string[]; +} + +export type HelpProseMap = Record; + +/** + * Labels for the help components' own chrome. These live here rather than in the + * plugin's 25 shared locale files, which this work must not touch. + */ +export interface HelpUiStrings { + help: string; + searchHelp: string; + clear: string; + close: string; + moreInHelp: string; + replayTour: string; + skip: string; + next: string; + noResults: string; + sectionTask: string; + sectionToolbar: string; + sectionGrid: string; + sectionDayCell: string; + sectionFlex: string; +} + +export type HelpUiKey = keyof HelpUiStrings; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts new file mode 100644 index 00000000..f562dfad --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts @@ -0,0 +1,389 @@ +import { HelpProseMap, HelpUiStrings } from '../help.model'; + +export const enUS: HelpProseMap = { + // ---------------------------------------------------------------- tasks ---- + 'task.registerVacation': { + title: 'Register vacation for a worker', + short: 'Mark a day as vacation. The day still counts as the hours the worker was planned to work.', + steps: [ + 'Click the day in the grid where the vacation starts.', + 'Tick Vacation in the list of day types.', + 'Click Save. The day now counts as the planned hours.', + 'Repeat for each vacation day.', + ], + detail: 'A day carries one day type at a time — ticking Vacation clears any other type already set. Use Vacation day off instead if the day should count as zero hours.', + keywords: ['vacation', 'holiday', 'time off', 'leave', 'absent', 'away', 'ferie', 'annual leave'], + }, + 'task.registerSickness': { + title: 'Register sickness', + short: 'Mark a day as a sick day. The day counts as the hours the worker was planned to work, so the plan is not left short.', + steps: [ + 'Click the day in the grid.', + 'Tick Sick. For a sick child, tick Children 1st sick day or Children 2st sick day instead.', + 'Click Save.', + 'Repeat for each day the worker is away.', + ], + detail: 'Sick, Children 1st sick day and Children 2st sick day all set the day to the hours planned for it. A day carries one type at a time, so ticking one of them clears whatever was ticked before.', + keywords: ['sick', 'sickness', 'ill', 'illness', 'sick day', 'sick child', 'absence', 'off ill'], + }, + 'task.registerDayOff': { + title: 'Register a day off', + short: 'Mark a day as a day off. Unlike vacation, the day counts as zero hours.', + steps: [ + 'Click the day in the grid.', + 'Tick Day off, or Vacation day off if it comes out of the vacation balance.', + 'Click Save. The day now counts as zero hours.', + ], + detail: 'Day off and Vacation day off both set the day to zero hours. Vacation, sickness, course and the other day types keep the planned hours instead. This is the difference to watch for.', + keywords: ['day off', 'off', 'free', 'not working', 'zero hours', 'vacation day off', 'rest day'], + }, + 'task.correctRegisteredTime': { + title: 'Correct a time a worker registered', + short: 'Change the start, pause or stop that was registered on a day, when it is wrong or was never stopped.', + steps: [ + 'Click the day in the grid to open it.', + 'In the registered times, click the field to correct and pick the right time.', + 'Use the bin button next to a field to empty it instead.', + 'Click Save. The day total and the flex line for that day are recalculated.', + ], + detail: 'A day with a start but no stop shows a warning triangle in the grid. That is the usual sign that a shift was never stopped and needs a stop time.', + keywords: ['correct', 'fix', 'wrong time', 'edit', 'change', 'adjust', 'forgot to stop', 'missing stop', 'clock out'], + }, + 'task.addMissingRegistration': { + title: 'Add a registration a worker never made', + short: 'Fill in start, pause and stop yourself for a day the worker did not register.', + steps: [ + 'Click the empty day in the grid.', + 'Fill in Start, Pause and Stop under the registered times.', + 'Write a short note in the office comment so the day can be explained later.', + 'Click Save.', + ], + detail: 'You can only fill in registered times for today and days in the past. On a future day those fields are not available, because there is nothing to register yet.', + keywords: ['missing', 'forgot', 'no registration', 'empty day', 'add hours', 'manual', 'did not register', 'blank'], + }, + 'task.addExtraShift': { + title: 'Put a second or third shift on one day', + short: 'A day can hold more than one shift. Shift 1 and shift 2 are always there; shift 3, 4 and 5 appear only for workers set up for them.', + steps: [ + 'Click the day in the grid.', + 'Fill in shift 1 as usual.', + 'Fill in the next shift row below it — start, pause and stop.', + 'Click Save. The grid shows each shift on its own line in the day.', + ], + detail: 'The 3v badge in the worker row tells you whether shift 3, 4 or 5 is switched on for that worker. Hover it to see which ones. If a worker has no rows beyond shift 2, that worker is set up for two shifts.', + keywords: ['extra shift', 'second shift', 'third shift', 'double shift', 'split shift', 'two shifts', 'more shifts', '3v'], + }, + 'task.changePlannedHours': { + title: 'Change what a worker is planned for', + short: 'Set the plan for a day either as shift times or as a plain number of hours.', + steps: [ + 'Click the day in the grid.', + 'Either fill in Start, Pause and Stop under the planned times, or type a number in Plan hours.', + 'Click Save.', + 'The grid shows the plan for that day with a calendar icon.', + ], + detail: 'Planned shift times and Plan hours describe the same plan. Filling in shift times recalculates Plan hours for you; typing a number instead is the quick way when the exact start and stop do not matter.', + keywords: ['plan', 'planned hours', 'schedule', 'roster', 'change plan', 'plan hours', 'expected hours', 'norm'], + }, + 'task.payOutFlex': { + title: 'Pay out flex hours', + short: 'Record a flex payout on a single day, so the hours leave the flex balance.', + steps: [ + 'Click the day the payout belongs to.', + 'Type the number of hours in the flex paid out field.', + 'Click Save.', + 'The grid shows a payments icon with that amount on the day.', + ], + detail: 'Record the payout on the day it was agreed or paid, not spread over several days. Check the flex balance shown on that day and the days after it to confirm the result you expect.', + keywords: ['flex', 'pay out', 'payout', 'paid out', 'cash out', 'settle flex', 'flex payment', 'clear flex'], + }, + 'task.exportForPayroll': { + title: 'Get the hours out for payroll', + short: 'Download the period as an Excel file, for one worker or for everyone.', + steps: [ + 'Click the Excel button in the toolbar.', + 'Set the date range for the period you need.', + 'Leave Worker empty for everyone, or pick one worker.', + 'Click the download button. The file is created for the range you chose, not for the range shown in the grid.', + ], + detail: 'The date range inside the download dialog is its own setting. Changing it does not change what the grid shows, and the grid range does not change the file.', + keywords: ['export', 'excel', 'payroll', 'wages', 'salary', 'download', 'report', 'spreadsheet', 'xlsx', 'timesheet'], + }, + 'task.whoChangedThis': { + title: 'See who changed a day', + short: 'Every change to a day is logged with a time, the field that changed, the old and new value, and the name of the person who changed it.', + steps: [ + 'Click the day in the grid to open it.', + 'Click the history button next to the date at the top.', + 'Read the activity log — newest first, grouped by day.', + 'Click Close when you are done.', + ], + detail: 'Use this before correcting a day you did not register yourself. It tells you whether a value came from the worker\'s phone or was typed in later, and by whom.', + keywords: ['history', 'who changed', 'log', 'audit', 'activity', 'changes', 'version', 'trail', 'previous value'], + }, + 'task.whereWasThisRegistered': { + title: 'See where a registration was made', + short: 'Where a worker\'s phone recorded a position or a photo with a start, pause or stop, you can open it from the day.', + steps: [ + 'Click the day in the grid to open it.', + 'Look for a pin or a camera button next to the registered time you are checking.', + 'Click the pin to open the location on a map, or the camera to open the photo.', + 'Close the side panel when you are done.', + ], + detail: 'The buttons only appear for times that actually carry a position or a photo. A time registered without them shows no button, which is normal.', + keywords: ['gps', 'location', 'map', 'where', 'position', 'photo', 'picture', 'snapshot', 'camera', 'proof'], + }, + 'task.filterToOneTeam': { + title: 'Show only one team', + short: 'Use tags to narrow the grid to the group of workers you plan for.', + steps: [ + 'Open Tags in the toolbar and pick one or more tags.', + 'The grid now shows only workers carrying those tags.', + 'You can also click a tag on a worker row to filter by it.', + 'Clear the Tags field to see everyone again.', + ], + detail: 'Tags come from how your workers are grouped — team, department, location. A worker can carry several tags and will show up under each of them.', + keywords: ['tag', 'tags', 'team', 'group', 'department', 'filter', 'narrow', 'subset', 'only my people'], + }, + + // -------------------------------------------------------------- toolbar ---- + 'toolbar.showResigned': { + title: 'Show resigned', + short: 'Switches the grid over to workers who have left, so you can still look at their hours.', + detail: 'While this is on, the grid shows resigned workers instead of the current ones. Switch it off again to get your normal list back.', + keywords: ['resigned', 'left', 'former', 'quit', 'inactive', 'ex-employee', 'terminated', 'past workers'], + }, + 'toolbar.navBackward': { + title: 'Previous period', + short: 'Moves the grid one full period back — the same number of days you are looking at now.', + detail: 'If the grid shows seven days, this steps back seven days. The period length itself does not change; only the dates do.', + keywords: ['back', 'previous', 'earlier', 'last week', 'go back', 'backwards', 'prior period'], + }, + 'toolbar.navForward': { + title: 'Next period', + short: 'Moves the grid one full period forward — the same number of days you are looking at now.', + detail: 'Use this to plan ahead. Days in the future show the plan only; there is nothing registered on them yet.', + keywords: ['forward', 'next', 'later', 'next week', 'ahead', 'forwards', 'coming period'], + }, + 'toolbar.workerFilter': { + title: 'Worker', + short: 'Narrows the grid to a single worker. It appears only when you have more than one worker to choose from.', + detail: 'Clear the field to get the full list back. This filter is separate from the tag filter — the two work together.', + keywords: ['worker', 'employee', 'person', 'staff', 'single worker', 'filter', 'one person', 'site'], + }, + 'toolbar.tagFilter': { + title: 'Tags', + short: 'Narrows the grid to workers carrying the tags you pick. You can pick several at once.', + detail: 'Clicking a tag on a worker row does the same thing. Empty the field to see everyone again.', + keywords: ['tags', 'tag', 'team', 'group', 'department', 'category', 'label', 'filter'], + }, + 'toolbar.dateRange': { + title: 'Date range', + short: 'Sets which days the grid shows. Pick a start date and an end date.', + detail: 'The grid draws one column per day in the range, so a long range makes narrow columns. The arrows next to this field step the whole range backwards and forwards by its own length.', + keywords: ['date', 'dates', 'range', 'period', 'week', 'month', 'from', 'to', 'calendar', 'timeframe'], + }, + 'toolbar.downloadExcel': { + title: 'Download Excel', + short: 'Opens a dialog that builds an Excel file of the hours, for one worker or for everyone.', + detail: 'The dialog has its own date range, independent of the grid. Leave Worker empty and the button reads "Download Excel (all workers)"; pick a worker and you get that worker alone.', + keywords: ['excel', 'download', 'export', 'file', 'spreadsheet', 'report', 'xlsx', 'print', 'payroll'], + }, + 'toolbar.payrollExport': { + title: 'Export to payroll', + short: 'Opens a dialog that sends a whole pay period to the payroll system set up for your company.', + detail: 'Set a start and end date and the dialog previews how many workers and how many pay lines the period holds. If part of the period was already exported, it says so with the date, and the button changes to "Export anyway".', + keywords: ['payroll', 'export', 'wages', 'salary', 'pay period', 'pay lines', 'send to payroll', 'lon'], + }, + 'toolbar.reload': { + title: 'Reload table', + short: 'Fetches the grid again, so registrations that came in from phones while you were looking appear.', + detail: 'The grid does not refresh itself. Use this when you are waiting for a worker to register something, or if a number looks stale.', + keywords: ['reload', 'refresh', 'update', 'reset', 'fetch', 'stale', 'not showing', 'sync'], + }, + + // ----------------------------------------------------------------- grid ---- + 'grid.nameColumn': { + title: 'The worker column', + short: 'The pinned first column of the grid. It shows the worker, the hours for the period, any tags, and small icons for the rules that apply to that worker.', + detail: 'The ring around the picture fills up as the worker gets through the planned hours for the period shown. The column stays put while you scroll the days sideways.', + keywords: ['name', 'worker', 'employee', 'first column', 'left column', 'person', 'avatar', 'picture', 'row'], + }, + 'grid.tagChips': { + title: 'Tags on a worker row', + short: 'The small labels under a worker\'s name. They show the groups that worker belongs to.', + detail: 'Click a tag to filter the grid to that tag — the same result as picking it in the Tags field in the toolbar.', + keywords: ['tag', 'tags', 'chip', 'label', 'team', 'group', 'department', 'badge', 'filter by tag'], + }, + 'grid.settingsStrip': { + title: 'The icons under a worker\'s name', + short: 'A row of small icons showing which rules apply to that worker. A lit icon means the rule is on; a dimmed one means it is off.', + detail: 'From left to right: kr is the pay rule set in use, the next icon is how the worker registers time on the phone, the moon means shifts may run past midnight, the two bars mean breaks are calculated automatically, 1m means times are picked in one-minute steps rather than five, and 3v means the worker has shift 3, 4 or 5 switched on. Hover any of them to read what it says.', + keywords: ['icons', 'settings', 'rules', 'strip', 'badges', 'symbols', 'kr', '1m', '3v', 'pay rule', 'auto break', 'midnight'], + }, + 'grid.dayCellAnatomy': { + title: 'What a day cell shows', + short: 'One cell per worker per day, read from top to bottom: what was planned, what was registered, and how the day ended up.', + detail: 'The calendar icon marks planned shift times. The arrow-in and arrow-out icons mark the registered start and stop; a warning triangle instead of a stop means the shift was never stopped. Below that come the total break, the hours the day counts as, any flex paid out, and the flex balance up to and including that day, in red when it is negative. Comments follow: a face icon for the worker\'s comment, a house icon for the office comment. Totals only appear on today and on days in the past.', + keywords: ['cell', 'day', 'column', 'read', 'anatomy', 'icons', 'meaning', 'what does this mean', 'symbols', 'layout'], + }, + 'grid.weeklyPlannedHours': { + title: 'Hours in the worker column', + short: 'The hours next to the worker\'s name are the total for the whole period on screen — planned first, then the hours registered so far in brackets.', + detail: 'Do not confuse this with the hours inside a single day cell, which are for that day only. Change the date range and this total changes with it, because it always covers the days shown.', + keywords: ['total', 'weekly', 'week', 'sum', 'planned hours', 'worked hours', 'period total', 'brackets', 'parentheses'], + }, + 'grid.messageIcons': { + title: 'Day type icons in a day cell', + short: 'The icon at the right edge of a day cell shows the day type set on that day — vacation, sickness, course and so on.', + detail: 'Hover the icon to read which type it is. Sick days and children\'s sick days show in red; the rest show in blue. A day with no type set has no icon.', + keywords: ['icon', 'symbol', 'vacation icon', 'sick icon', 'plane', 'school', 'day type', 'absence', 'what is this icon', 'colour'], + }, + 'grid.sortName': { + title: 'Sorting by name', + short: 'Click the Name header to sort the workers, and click again to reverse the order.', + detail: 'Only the worker column sorts. The day columns stay in date order.', + keywords: ['sort', 'order', 'alphabetical', 'a-z', 'name', 'reorder', 'arrange'], + }, + 'grid.openDay': { + title: 'Opening a day', + short: 'Click anywhere in a day cell to open that day for editing.', + detail: 'The dialog that opens covers one worker on one date. Everything on the day — plan, registered times, day type, comments — is edited there and takes effect when you save.', + keywords: ['open', 'click', 'edit day', 'day dialog', 'change day', 'double click', 'edit cell', 'how to edit'], + }, + + // -------------------------------------------------------------- day cell ---- + 'dayCell.versionHistory': { + title: 'History button', + short: 'Opens the activity log for this day — every change, newest first, with the time, what changed, from what to what, and who did it.', + detail: 'Where a change carries a position or a photo, the log gives you a link to open it. Use the log before you correct a day you did not register yourself.', + keywords: ['history', 'log', 'activity', 'audit', 'who changed', 'changes', 'version', 'previous', 'trail'], + }, + 'dayCell.plannedTimes': { + title: 'Planned times', + short: 'Start, pause and stop for what the worker is meant to do on this day. One set of fields per shift.', + detail: 'Filling these in recalculates Plan hours for the day. The pause cannot be longer than the distance between start and stop. The grid shows planned times with a calendar icon.', + keywords: ['planned', 'plan', 'schedule', 'expected', 'start', 'stop', 'pause', 'break', 'shift times', 'roster'], + }, + 'dayCell.actualTimes': { + title: 'Registered times', + short: 'Start, pause and stop as they were actually registered for this day. These are the values that decide what the day counts as.', + detail: 'A time here usually came from the worker\'s phone, but you can type one in or correct one. There are no registered times on a day in the future.', + keywords: ['actual', 'registered', 'real', 'worked', 'clocked', 'start', 'stop', 'pause', 'punch', 'recorded'], + }, + 'dayCell.shiftCount': { + title: 'How many shifts a day has', + short: 'Shift 1 and shift 2 are always available. Shift 3, 4 and 5 appear only for workers set up with them.', + detail: 'The 3v badge in the worker row tells you which extra shifts a worker has switched on. Hover it to see whether that is shift 3, 4, 5 or a combination.', + keywords: ['shift', 'shifts', 'second shift', 'third shift', 'fourth', 'fifth', 'extra shift', 'split', 'double', '3v'], + }, + 'dayCell.resetField': { + title: 'Empty a time field', + short: 'The bin button next to a time field clears that field.', + detail: 'Clearing is not the same as setting a time of 00:00. Use it when a value should not be there at all, then save.', + keywords: ['clear', 'delete', 'empty', 'remove', 'bin', 'trash', 'reset', 'undo', 'blank field'], + }, + 'dayCell.resetPauseToRecorded': { + title: 'Reset pause to recorded', + short: 'Puts the pause back to the length that was actually registered, undoing a pause you typed in by hand.', + detail: 'The field immediately shows the recorded length so you can see what you are going back to before you save. If that is not what you want, pick a different pause instead of saving.', + keywords: ['pause', 'break', 'reset', 'restore', 'recorded', 'original', 'undo pause', 'revert', 'back to registered'], + }, + 'dayCell.gps': { + title: 'Location button', + short: 'Opens the position that was recorded with this start, pause or stop on a map beside the day.', + detail: 'The button is only there for times that actually carry a position. Close the side panel to get the dialog back to its normal width.', + keywords: ['gps', 'location', 'map', 'position', 'pin', 'where', 'coordinates', 'geolocation', 'address'], + }, + 'dayCell.snapshot': { + title: 'Photo button', + short: 'Opens the photo that was taken with this start, pause or stop beside the day.', + detail: 'The button is only there for times that actually carry a photo. Close the side panel to get the dialog back to its normal width.', + keywords: ['photo', 'picture', 'snapshot', 'camera', 'image', 'selfie', 'proof', 'evidence'], + }, + 'dayCell.futureDisabled': { + title: 'Days in the future', + short: 'On a day that has not happened yet you can set the plan, but the registered times and the flex fields are not available.', + detail: 'That is expected, not a fault: there is nothing registered on a future day and no balance to show for it. Plan the day now, and come back after the date to see what was registered.', + keywords: ['future', 'greyed out', 'disabled', 'cannot edit', 'locked', 'read only', 'not available', 'tomorrow', 'next week'], + }, + 'dayCell.planHours': { + title: 'Plan hours', + short: 'The number of hours the worker is planned for on this day, as a plain number.', + detail: 'Filling in planned shift times recalculates this for you; typing a number here is the quicker route when the exact start and stop do not matter. The total across all shifts on one day cannot go above 24.', + keywords: ['plan hours', 'planned', 'hours', 'norm', 'expected', 'target', 'schedule', 'number of hours'], + }, + 'dayCell.nettoOverride': { + title: 'Netto hours override', + short: 'What the day counts as, when it should not be the hours that were registered. The field appears only when an override is in force on the day.', + detail: 'Ticking a day type sets this for you: Day off and Vacation day off set it to zero, and every other type sets it to the hours planned for the day. You can also type a value in yourself. The grid then shows this figure as the day\'s hours instead of the registered total.', + keywords: ['netto', 'override', 'counts as', 'adjust', 'correction', 'manual hours', 'net hours', 'force', 'set hours'], + }, + 'dayCell.paidOutFlex': { + title: 'Flex paid out', + short: 'The number of flex hours paid out on this day.', + detail: 'Record a payout on the day it belongs to. The grid then shows a payments icon with that amount in the day cell. Check the flex balance on that day and the days after it to confirm the result you expect.', + keywords: ['flex', 'paid out', 'payout', 'pay out', 'cash', 'settle', 'flex payment', 'withdraw', 'clear flex'], + }, + 'dayCell.flags': { + title: 'Day type', + short: 'Marks what kind of day this is — vacation, sickness, course and so on. They look like checkboxes, but a day carries only one type at a time: ticking a new one unticks the previous one.', + detail: 'Ticking a type also sets what the day counts as. Day off and Vacation day off set it to zero hours. Vacation, Sick, Course, Leave of absence, Children 1st sick day, Children 2st sick day, Time off, Maternity leave, Holiday and Pregnancy-related absence all set it to the hours planned for that day. So Vacation and Vacation day off sit next to each other and do the opposite of one another — pick the one that matches what the day should count as. Unticking the type again removes that setting.', + keywords: ['day type', 'vacation', 'sickness', 'sick', 'course', 'maternity', 'leave', 'holiday', 'flag', 'absence', 'checkbox', 'day off', 'time off', 'mark day'], + }, + 'dayCell.commentOffice': { + title: 'Office comment', + short: 'A note you write on the day. It shows in the grid on that day with a house icon.', + detail: 'Use it to say why a day was changed — a phone left at home, a shift agreed by telephone. The worker\'s own comment appears separately with a face icon and is not editable here.', + keywords: ['comment', 'note', 'remark', 'office', 'message', 'why', 'explanation', 'text', 'annotation'], + }, + 'dayCell.save': { + title: 'Save', + short: 'Writes the day and closes the dialog. The grid then reloads and highlights the day you changed.', + detail: 'The button stays disabled while something on the day is invalid — the field in question shows the reason in red. Cancel closes without writing anything.', + keywords: ['save', 'ok', 'apply', 'confirm', 'store', 'submit', 'commit', 'disabled', 'greyed out', 'cannot save'], + }, + 'dayCell.oneMinuteIntervals': { + title: 'One-minute steps', + short: 'The time pickers move in five-minute steps for most workers, and in one-minute steps for workers set up that way.', + detail: 'The 1m badge in the worker row tells you which applies. If a picker will not let you land on the exact minute you want, that worker is on five-minute steps.', + keywords: ['minutes', 'one minute', '1m', 'five minute', 'interval', 'step', 'rounding', 'granularity', 'exact time', 'picker'], + }, + + // ----------------------------------------------------------------- flex ---- + 'flex.whatIsFlex': { + title: 'What flex is', + short: 'Flex is the difference between what a worker was planned for and what the day ended up counting as. A long day builds flex up; a short day draws it down.', + detail: 'The day editor shows the flex for that single day in its own field. The grid shows the running balance instead, so the two figures are different things.', + keywords: ['flex', 'flexitime', 'overtime', 'balance', 'time bank', 'plus hours', 'minus hours', 'what is flex', 'difference'], + }, + 'flex.sumFlex': { + title: 'Flex balance', + short: 'The running flex balance. The day editor shows it both at the start of the day and up to and including that day; the grid shows the balance for each day.', + detail: 'A negative balance shows in red. To see how a balance came about, step through the days before it and read the flex line in each — that shows you where it moved.', + keywords: ['flex balance', 'sum', 'total flex', 'running', 'accumulated', 'saldo', 'negative', 'red', 'minus', 'owed'], + }, + 'flex.paidOutFlexRelation': { + title: 'Flex and payouts', + short: 'Hours paid out are recorded on one day and shown as a payments line in that day cell. The balance from that day onwards is what the page has stored for the worker.', + detail: 'If a balance does not look the way you expect after a payout, open the day the payout was recorded on, check the amount in the flex paid out field, and read the activity log for that day. That is where the answer will be.', + keywords: ['paid out', 'payout', 'flex', 'balance', 'does not add up', 'wrong balance', 'missing', 'settled', 'deducted'], + }, +}; + +export const enUSUi: HelpUiStrings = { + help: 'Help', + searchHelp: 'Search help', + clear: 'Clear', + close: 'Close', + moreInHelp: 'More in help', + replayTour: 'Take the tour', + skip: 'Skip', + next: 'Next', + noResults: 'Nothing matched. Here is what people usually need:', + sectionTask: 'Common tasks', + sectionToolbar: 'Toolbar', + sectionGrid: 'The grid', + sectionDayCell: 'Editing a day', + sectionFlex: 'Flex', +}; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts new file mode 100644 index 00000000..41228252 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts @@ -0,0 +1,14 @@ +import { HelpProseMap, HelpUiStrings } from '../help.model'; +import { enUS, enUSUi } from './enUS'; + +/** Locale code (as ngx-translate reports it) to prose. Partial maps fall back per entry. */ +export const HELP_LOCALES: Record> = { + 'en-US': enUS, +}; + +export const HELP_UI_LOCALES: Record = { + 'en-US': enUSUi, +}; + +export const HELP_FALLBACK: HelpProseMap = enUS; +export const HELP_UI_FALLBACK: HelpUiStrings = enUSUi; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.spec.ts new file mode 100644 index 00000000..d9e0b5b8 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.spec.ts @@ -0,0 +1,81 @@ +import { PLANNING_HELP_ENTRIES } from './planning-help.registry'; +import { enUS } from './i18n/enUS'; +import { HelpEntry, HelpEntryId } from './help.model'; + +describe('planning help registry', () => { + const byId = new Map( + PLANNING_HELP_ENTRIES.map(e => [e.id, e]), + ); + + it('has no duplicate ids', () => { + expect(byId.size).toBe(PLANNING_HELP_ENTRIES.length); + }); + + it('gives every entry English prose with a title, short text and a keyword', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = enUS[entry.id]; + expect(prose).toBeDefined(); + expect(prose.title.length).toBeGreaterThan(0); + expect(prose.short.length).toBeGreaterThan(0); + expect(prose.keywords.length).toBeGreaterThan(0); + } + }); + + it('gives every task steps, and no control steps', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = enUS[entry.id]; + if (entry.kind === 'task') { + expect(prose.steps?.length ?? 0).toBeGreaterThan(0); + } else { + expect(prose.steps).toBeUndefined(); + } + } + }); + + it('keeps tasks out of tours and off the page', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.kind === 'task')) { + expect(entry.anchor).toBeUndefined(); + expect(entry.tourStep).toBeUndefined(); + expect(entry.tour).toBeUndefined(); + } + }); + + it('gives every tour step a tour and an anchor', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.tourStep !== undefined)) { + expect(entry.tour).toBeDefined(); + expect(entry.anchor).toBeDefined(); + } + }); + + it('numbers tour steps uniquely within each tour', () => { + for (const tour of ['page', 'dialog'] as const) { + const steps = PLANNING_HELP_ENTRIES + .filter(e => e.tour === tour && e.tourStep !== undefined) + .map(e => e.tourStep as number); + expect(new Set(steps).size).toBe(steps.length); + expect(steps.length).toBeGreaterThan(0); + } + }); + + it('resolves every related id', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + for (const related of entry.related ?? []) { + expect(byId.has(related)).toBe(true); + } + } + }); + + it('marks exactly one entry admin-only', () => { + const adminOnly = PLANNING_HELP_ENTRIES.filter(e => e.adminOnly); + expect(adminOnly.map(e => e.id)).toEqual(['toolbar.payrollExport']); + }); + + it('never mentions administrators in user-facing copy', () => { + const banned = /\badmin(istrator)?s?\b/i; + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = enUS[entry.id]; + const text = [prose.title, prose.short, prose.detail ?? '', ...(prose.steps ?? [])].join(' '); + expect(text).not.toMatch(banned); + } + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts new file mode 100644 index 00000000..68303105 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts @@ -0,0 +1,87 @@ +import { HelpEntry } from './help.model'; + +export const PLANNING_HELP_ENTRIES: HelpEntry[] = [ + // ---- tasks (no anchor, no tour) ---- + { id: 'task.registerVacation', kind: 'task', section: 'task', + related: ['dayCell.flags', 'dayCell.nettoOverride', 'dayCell.save'] }, + { id: 'task.registerSickness', kind: 'task', section: 'task', + related: ['dayCell.flags', 'dayCell.save'] }, + { id: 'task.registerDayOff', kind: 'task', section: 'task', + related: ['dayCell.flags', 'dayCell.nettoOverride'] }, + { id: 'task.correctRegisteredTime', kind: 'task', section: 'task', + related: ['dayCell.actualTimes', 'dayCell.resetField', 'dayCell.save'] }, + { id: 'task.addMissingRegistration', kind: 'task', section: 'task', + related: ['grid.openDay', 'dayCell.actualTimes', 'dayCell.save'] }, + { id: 'task.addExtraShift', kind: 'task', section: 'task', + related: ['dayCell.shiftCount', 'grid.settingsStrip'] }, + { id: 'task.changePlannedHours', kind: 'task', section: 'task', + related: ['dayCell.plannedTimes', 'dayCell.planHours'] }, + { id: 'task.payOutFlex', kind: 'task', section: 'task', + related: ['dayCell.paidOutFlex', 'flex.sumFlex'] }, + { id: 'task.exportForPayroll', kind: 'task', section: 'task', + related: ['toolbar.downloadExcel'] }, + { id: 'task.whoChangedThis', kind: 'task', section: 'task', + related: ['dayCell.versionHistory'] }, + { id: 'task.whereWasThisRegistered', kind: 'task', section: 'task', + related: ['dayCell.gps', 'dayCell.snapshot'] }, + { id: 'task.filterToOneTeam', kind: 'task', section: 'task', + related: ['toolbar.tagFilter', 'grid.tagChips'] }, + + // ---- toolbar ---- + { id: 'toolbar.showResigned', kind: 'control', section: 'toolbar', anchor: 'toolbar.showResigned' }, + { id: 'toolbar.navBackward', kind: 'control', section: 'toolbar', anchor: 'toolbar.navBackward' }, + { id: 'toolbar.navForward', kind: 'control', section: 'toolbar', anchor: 'toolbar.navForward', + tour: 'page', tourStep: 2 }, + { id: 'toolbar.workerFilter', kind: 'control', section: 'toolbar', anchor: 'toolbar.workerFilter', + tour: 'page', tourStep: 3 }, + { id: 'toolbar.tagFilter', kind: 'control', section: 'toolbar', anchor: 'toolbar.tagFilter' }, + { id: 'toolbar.dateRange', kind: 'control', section: 'toolbar', anchor: 'toolbar.dateRange', + tour: 'page', tourStep: 1 }, + { id: 'toolbar.downloadExcel', kind: 'control', section: 'toolbar', anchor: 'toolbar.downloadExcel', + tour: 'page', tourStep: 7 }, + { id: 'toolbar.payrollExport', kind: 'control', section: 'toolbar', anchor: 'toolbar.payrollExport', + tour: 'page', tourStep: 8, adminOnly: true }, + { id: 'toolbar.reload', kind: 'control', section: 'toolbar', anchor: 'toolbar.reload' }, + + // ---- grid ---- + { id: 'grid.nameColumn', kind: 'control', section: 'grid', anchor: 'grid.nameColumn', + tour: 'page', tourStep: 4 }, + { id: 'grid.tagChips', kind: 'control', section: 'grid', anchor: 'grid.tagChips' }, + { id: 'grid.settingsStrip', kind: 'control', section: 'grid', anchor: 'grid.settingsStrip' }, + { id: 'grid.dayCellAnatomy', kind: 'control', section: 'grid', anchor: 'grid.dayCellAnatomy', + tour: 'page', tourStep: 5 }, + { id: 'grid.weeklyPlannedHours', kind: 'control', section: 'grid', anchor: 'grid.weeklyPlannedHours' }, + { id: 'grid.messageIcons', kind: 'control', section: 'grid', anchor: 'grid.messageIcons' }, + { id: 'grid.sortName', kind: 'control', section: 'grid', anchor: 'grid.sortName' }, + { id: 'grid.openDay', kind: 'control', section: 'grid', anchor: 'grid.openDay', + tour: 'page', tourStep: 6 }, + + // ---- day-cell dialog ---- + { id: 'dayCell.versionHistory', kind: 'control', section: 'dayCell', anchor: 'dayCell.versionHistory' }, + { id: 'dayCell.plannedTimes', kind: 'control', section: 'dayCell', anchor: 'dayCell.plannedTimes', + tour: 'dialog', tourStep: 1 }, + { id: 'dayCell.actualTimes', kind: 'control', section: 'dayCell', anchor: 'dayCell.actualTimes', + tour: 'dialog', tourStep: 2 }, + { id: 'dayCell.shiftCount', kind: 'control', section: 'dayCell', anchor: 'dayCell.shiftCount' }, + { id: 'dayCell.resetField', kind: 'control', section: 'dayCell', anchor: 'dayCell.resetField' }, + { id: 'dayCell.resetPauseToRecorded', kind: 'control', section: 'dayCell', anchor: 'dayCell.resetPauseToRecorded' }, + { id: 'dayCell.gps', kind: 'control', section: 'dayCell', anchor: 'dayCell.gps' }, + { id: 'dayCell.snapshot', kind: 'control', section: 'dayCell', anchor: 'dayCell.snapshot' }, + { id: 'dayCell.futureDisabled', kind: 'control', section: 'dayCell', anchor: 'dayCell.futureDisabled' }, + { id: 'dayCell.planHours', kind: 'control', section: 'dayCell', anchor: 'dayCell.planHours', + tour: 'dialog', tourStep: 3 }, + { id: 'dayCell.nettoOverride', kind: 'control', section: 'dayCell', anchor: 'dayCell.nettoOverride', + tour: 'dialog', tourStep: 5 }, + { id: 'dayCell.paidOutFlex', kind: 'control', section: 'dayCell', anchor: 'dayCell.paidOutFlex' }, + { id: 'dayCell.flags', kind: 'control', section: 'dayCell', anchor: 'dayCell.flags', + tour: 'dialog', tourStep: 4 }, + { id: 'dayCell.commentOffice', kind: 'control', section: 'dayCell', anchor: 'dayCell.commentOffice' }, + { id: 'dayCell.save', kind: 'control', section: 'dayCell', anchor: 'dayCell.save', + tour: 'dialog', tourStep: 6 }, + { id: 'dayCell.oneMinuteIntervals', kind: 'control', section: 'dayCell', anchor: 'dayCell.oneMinuteIntervals' }, + + // ---- flex ---- + { id: 'flex.whatIsFlex', kind: 'control', section: 'flex', anchor: 'flex.whatIsFlex' }, + { id: 'flex.sumFlex', kind: 'control', section: 'flex', anchor: 'flex.sumFlex' }, + { id: 'flex.paidOutFlexRelation', kind: 'control', section: 'flex', anchor: 'flex.paidOutFlexRelation' }, +]; From 8d9f554762b76bec3870132e54b73bf0ba51910f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:30:52 +0200 Subject: [PATCH 08/38] fix(help): state day-type mutual exclusivity in the day-off task task.registerDayOff explained only that Day off and Vacation day off set the day to zero hours, and never said that ticking one unticks whatever day type was set before. The other two leave tasks both say so. Reuse their phrasing so all three carry both halves of the rule. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts index f562dfad..133121ac 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts @@ -34,7 +34,7 @@ export const enUS: HelpProseMap = { 'Tick Day off, or Vacation day off if it comes out of the vacation balance.', 'Click Save. The day now counts as zero hours.', ], - detail: 'Day off and Vacation day off both set the day to zero hours. Vacation, sickness, course and the other day types keep the planned hours instead. This is the difference to watch for.', + detail: 'A day carries one day type at a time — ticking Day off or Vacation day off clears any other type already set. Both of them set the day to zero hours, while Vacation, sickness, course and the other day types keep the planned hours instead. This is the difference to watch for.', keywords: ['day off', 'off', 'free', 'not working', 'zero hours', 'vacation day off', 'rest day'], }, 'task.correctRegisteredTime': { From 6acb1a1b3d3ca1e543e353886f5100838b623a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:33:44 +0200 Subject: [PATCH 09/38] feat(help): resolve help prose by locale with per-entry English fallback Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../services/help-content.service.spec.ts | 61 +++++++++++++++++++ .../help/services/help-content.service.ts | 48 +++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts new file mode 100644 index 00000000..f7ae7d15 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts @@ -0,0 +1,61 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpContentService } from './help-content.service'; +import { enUS } from '../i18n/enUS'; + +describe('HelpContentService', () => { + let translate: { currentLang: string }; + + const make = (lang: string) => { + translate = { currentLang: lang }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + HelpContentService, + { provide: TranslateService, useValue: translate }, + ], + }); + return TestBed.inject(HelpContentService); + }; + + // These compare against the content file rather than a hard-coded string, so a + // copywriting choice made later in Task 1 cannot fail a resolution test. + it('returns English prose for an English locale', () => { + const service = make('en-US'); + expect(service.prose('toolbar.dateRange')).toEqual(enUS['toolbar.dateRange']); + }); + + it('falls back to English for a locale with no prose file', () => { + const service = make('de-DE'); + expect(service.prose('toolbar.dateRange')).toEqual(enUS['toolbar.dateRange']); + }); + + it('resolves a bare language code to its locale file', () => { + const service = make('da'); + expect(service.prose('toolbar.dateRange')).toBeDefined(); + }); + + it('hides admin-only entries from a non-admin', () => { + const service = make('en-US'); + const ids = service.entries({ isAdmin: false }).map(e => e.id); + expect(ids).not.toContain('toolbar.payrollExport'); + expect(service.entries({ isAdmin: true }).map(e => e.id)) + .toContain('toolbar.payrollExport'); + }); + + it('orders tour entries by step and drops admin-only steps for a non-admin', () => { + const service = make('en-US'); + const steps = service.tourEntries('page', { isAdmin: false }); + expect(steps.map(e => e.tourStep)).toEqual([...steps.map(e => e.tourStep)].sort((a, b) => (a ?? 0) - (b ?? 0))); + expect(steps.map(e => e.id)).not.toContain('toolbar.payrollExport'); + expect(service.tourEntries('page', { isAdmin: true }).map(e => e.id)) + .toContain('toolbar.payrollExport'); + }); + + it('never returns undefined prose for a registry id', () => { + const service = make('da'); + for (const entry of service.entries({ isAdmin: true })) { + expect(service.prose(entry.id).short.length).toBeGreaterThan(0); + } + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.ts new file mode 100644 index 00000000..ecbb8557 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpEntry, HelpEntryId, HelpProse, HelpTourName, HelpUiStrings } from '../help.model'; +import { PLANNING_HELP_ENTRIES } from '../planning-help.registry'; +import { HELP_FALLBACK, HELP_LOCALES, HELP_UI_FALLBACK, HELP_UI_LOCALES } from '../i18n'; + +@Injectable({ providedIn: 'root' }) +export class HelpContentService { + private readonly byId = new Map( + PLANNING_HELP_ENTRIES.map(entry => [entry.id, entry]), + ); + + constructor(private translateService: TranslateService) {} + + entry(id: HelpEntryId): HelpEntry | undefined { + return this.byId.get(id); + } + + /** Active locale, falling back to English one entry at a time. */ + prose(id: HelpEntryId): HelpProse { + return this.localeProse()[id] ?? HELP_FALLBACK[id]; + } + + entries(opts: { isAdmin: boolean }): HelpEntry[] { + return PLANNING_HELP_ENTRIES.filter(entry => !entry.adminOnly || opts.isAdmin); + } + + tourEntries(tour: HelpTourName, opts: { isAdmin: boolean }): HelpEntry[] { + return this.entries(opts) + .filter(entry => entry.tour === tour && entry.tourStep !== undefined) + .sort((a, b) => (a.tourStep as number) - (b.tourStep as number)); + } + + /** Chrome labels for the help components, resolved the same way as prose. */ + ui(): HelpUiStrings { + const lang = this.lang(); + return HELP_UI_LOCALES[lang] ?? HELP_UI_LOCALES[lang.split('-')[0]] ?? HELP_UI_FALLBACK; + } + + private localeProse(): Partial> { + const lang = this.lang(); + return HELP_LOCALES[lang] ?? HELP_LOCALES[lang.split('-')[0]] ?? HELP_FALLBACK; + } + + private lang(): string { + return this.translateService.currentLang || 'en-US'; + } +} From e11184f04613d41bb91c82990fe5d1275298a2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:48:05 +0200 Subject: [PATCH 10/38] feat(help): add Danish help content and search keywords All 48 registry entries in Danish, plus the help chrome labels, registered as the 'da' locale. Day-type copy names the labels the checkboxes actually render: Fridag and Afspadsering zero the day, while Ferie and the look-alike Ferie fridag keep the planned hours. Also covers the per-entry English fallback in HelpContentService, which was unreachable by test until a second locale existed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../time-planning-pn/help/i18n/da.spec.ts | 88 ++++ .../modules/time-planning-pn/help/i18n/da.ts | 393 ++++++++++++++++++ .../time-planning-pn/help/i18n/index.ts | 3 + .../services/help-content.service.spec.ts | 22 +- 4 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.spec.ts new file mode 100644 index 00000000..d042e60a --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.spec.ts @@ -0,0 +1,88 @@ +import { da, daUi } from './da'; +import { enUS, enUSUi } from './enUS'; +import { HELP_IDS, HelpUiKey } from '../help.model'; +import { PLANNING_HELP_ENTRIES } from '../planning-help.registry'; + +describe('Danish help content', () => { + it('covers every registry id', () => { + for (const id of HELP_IDS) { + expect(da[id]).toBeDefined(); + } + }); + + it('is actually translated, not copied from English', () => { + const identical = HELP_IDS.filter(id => da[id].short === enUS[id].short); + expect(identical).toEqual([]); + }); + + it('gives every task Danish steps', () => { + for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.kind === 'task')) { + expect(da[entry.id].steps?.length ?? 0).toBeGreaterThan(0); + } + }); + + it('gives every control a title and short text, and no steps', () => { + for (const entry of PLANNING_HELP_ENTRIES) { + const prose = da[entry.id]; + expect(prose.title.length).toBeGreaterThan(0); + expect(prose.short.length).toBeGreaterThan(0); + expect(prose.keywords.length).toBeGreaterThan(0); + if (entry.kind === 'control') { + expect(prose.steps).toBeUndefined(); + } + } + }); + + it('carries Danish search keywords the English file does not have', () => { + const danish = new Set(HELP_IDS.flatMap(id => da[id].keywords)); + for (const word of ['ferie', 'sygdom', 'fri', 'afspadsering', 'barsel']) { + expect(danish.has(word)).toBe(true); + } + }); + + it('carries the everyday words a Danish planner actually types', () => { + const danish = new Set(HELP_IDS.flatMap(id => da[id].keywords)); + for (const word of [ + 'løn', 'timer', 'vagt', 'fravær', 'kursus', 'orlov', + 'saldo', 'glemt', 'rettelse', 'feriefridag', 'fridag', 'flex', + ]) { + expect(danish.has(word)).toBe(true); + } + }); + + it('keeps keywords lower case and free of duplicates within an entry', () => { + for (const id of HELP_IDS) { + const keywords = da[id].keywords; + expect(keywords).toEqual(keywords.map(k => k.toLowerCase())); + expect(new Set(keywords).size).toBe(keywords.length); + } + }); + + // The Danish labels are not a word-for-word map of the English ones: DayOff is + // "Fridag", VacationDayOff is "Afspadsering", and TimeOff is "Ferie fridag" — which + // looks like a day off but keeps the planned hours. The day-type entry has to name + // all of them the way the checkboxes do. + it('names the day types that zero the day, and the look-alikes that do not', () => { + const text = [da['dayCell.flags'].short, da['dayCell.flags'].detail ?? ''].join(' '); + for (const label of ['Fridag', 'Afspadsering', 'Ferie', 'Ferie fridag', 'nul timer']) { + expect(text).toContain(label); + } + }); + + it('never mentions administrators', () => { + // \w* catches the Danish definite and possessive forms — administratoren, + // administratorens — which a content author is most likely to reach for. + const banned = /\badministrator\w*\b|\badmin\b/i; + for (const id of HELP_IDS) { + const prose = da[id]; + const text = [prose.title, prose.short, prose.detail ?? '', ...(prose.steps ?? [])].join(' '); + expect(text).not.toMatch(banned); + } + }); + + it('translates every chrome label', () => { + for (const key of Object.keys(enUSUi) as HelpUiKey[]) { + expect(daUi[key]?.length ?? 0).toBeGreaterThan(0); + } + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts new file mode 100644 index 00000000..a9067156 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts @@ -0,0 +1,393 @@ +import { HelpProseMap, HelpUiStrings } from '../help.model'; + +// The labels quoted here are the Danish ones the page actually renders, taken from +// the plugin's own da.ts: DayOff is "Fridag", VacationDayOff is "Afspadsering", +// TimeOff is "Ferie fridag". They are not a word-for-word map of the English labels, +// so the copy names them as the planner sees them on screen. +export const da: HelpProseMap = { + // -------------------------------------------------------------- opgaver ---- + 'task.registerVacation': { + title: 'Registrér ferie for en medarbejder', + short: 'Markér en dag som Ferie. Dagen tæller stadig som de timer, medarbejderen var planlagt til.', + steps: [ + 'Klik på dagen i skemaet, hvor ferien begynder.', + 'Sæt flueben ved Ferie i listen over dagtyper.', + 'Klik Gem. Dagen tæller nu som de planlagte timer.', + 'Gentag for hver feriedag.', + ], + detail: 'En dag har kun én dagtype ad gangen — sætter du flueben ved Ferie, forsvinder det flueben, der stod før. Skal dagen i stedet tælle som nul timer, er det Fridag eller Afspadsering, du skal bruge.', + keywords: ['ferie', 'feriedag', 'fri', 'fravær', 'orlov', 'sommerferie', 'ferieuge', 'holder fri'], + }, + 'task.registerSickness': { + title: 'Registrér sygdom', + short: 'Markér en dag som sygedag. Dagen tæller som de timer, medarbejderen var planlagt til, så planen ikke kommer til at mangle timer.', + steps: [ + 'Klik på dagen i skemaet.', + 'Sæt flueben ved Syg. Ved barns sygdom vælges Barns 1. sygedag eller Barns 2. sygedag i stedet.', + 'Klik Gem.', + 'Gentag for hver dag, medarbejderen er væk.', + ], + detail: 'Syg, Barns 1. sygedag og Barns 2. sygedag sætter alle dagen til de timer, der var planlagt for den. En dag har kun én dagtype ad gangen, så et nyt flueben fjerner det, der stod før.', + keywords: ['sygdom', 'syg', 'sygemeldt', 'sygedag', 'barns sygedag', 'barn syg', 'fravær', 'sygefravær'], + }, + 'task.registerDayOff': { + title: 'Registrér en fridag', + short: 'Markér en dag som Fridag. Modsat Ferie tæller dagen som nul timer.', + steps: [ + 'Klik på dagen i skemaet.', + 'Sæt flueben ved Fridag, eller ved Afspadsering hvis dagen er aftalt som afspadsering.', + 'Klik Gem. Dagen tæller nu som nul timer.', + ], + detail: 'En dag har kun én dagtype ad gangen — et flueben ved Fridag eller Afspadsering fjerner den type, der stod før. De to sætter begge dagen til nul timer, mens Ferie, Syg, Kursus og de øvrige dagtyper beholder de planlagte timer. Det er den forskel, man skal holde øje med.', + keywords: ['fridag', 'fri', 'afspadsering', 'afspadsere', 'nul timer', 'ikke på arbejde', 'hjemme', 'holder fri'], + }, + 'task.correctRegisteredTime': { + title: 'Ret en tid, en medarbejder har registreret', + short: 'Ret start, pause eller stop på en dag, når tiden er forkert eller aldrig blev stoppet.', + steps: [ + 'Klik på dagen i skemaet for at åbne den.', + 'Klik på det felt under de registrerede tider, der skal rettes, og vælg det rigtige klokkeslæt.', + 'Brug skraldespanden ved siden af et felt, hvis feltet i stedet skal være tomt.', + 'Klik Gem. Dagens timer og flexlinjen for dagen bliver regnet om.', + ], + detail: 'En dag med en start, men uden stop, viser en advarselstrekant i skemaet. Det er det typiske tegn på, at en vagt aldrig blev stoppet og mangler et stoptidspunkt.', + keywords: ['rettelse', 'ret', 'forkert tid', 'rediger', 'ændre', 'vagt', 'glemt at stoppe', 'mangler stop', 'stemple ud'], + }, + 'task.addMissingRegistration': { + title: 'Tilføj en registrering, medarbejderen aldrig fik lavet', + short: 'Udfyld selv start, pause og stop på en dag, hvor medarbejderen ikke har registreret noget.', + steps: [ + 'Klik på den tomme dag i skemaet.', + 'Udfyld Start, Pause og Stop under de registrerede tider.', + 'Skriv en kort note i Kommentar kontor, så dagen kan forklares senere.', + 'Klik Gem.', + ], + detail: 'Registrerede tider kan kun udfyldes for i dag og for dage, der er gået. På en fremtidig dag er felterne ikke tilgængelige, fordi der endnu ikke er noget at registrere.', + keywords: ['glemt', 'mangler', 'ingen registrering', 'tom dag', 'tilføj timer', 'manuelt', 'har ikke registreret', 'blank'], + }, + 'task.addExtraShift': { + title: 'Læg en vagt nummer to eller tre på samme dag', + short: 'En dag kan rumme flere vagter. Skift 1 og skift 2 er der altid; skift 3, 4 og 5 vises kun for medarbejdere, der er sat op til dem.', + steps: [ + 'Klik på dagen i skemaet.', + 'Udfyld skift 1 som sædvanlig.', + 'Udfyld rækken for det næste skift nedenunder — start, pause og stop.', + 'Klik Gem. I skemaet står hvert skift på sin egen linje i dagen.', + ], + detail: 'Mærket 3v i medarbejderrækken fortæller, om skift 3, 4 eller 5 er slået til for den medarbejder. Hold musen over det for at se hvilke. Har en medarbejder ingen rækker ud over skift 2, er den medarbejder sat op til to skift.', + keywords: ['ekstra vagt', 'to vagter', 'dobbeltvagt', 'delt vagt', 'andet skift', 'tredje skift', 'flere skift', '3v'], + }, + 'task.changePlannedHours': { + title: 'Ret hvad en medarbejder er planlagt til', + short: 'Sæt planen for dagen enten som skiftetider eller som et rent timetal.', + steps: [ + 'Klik på dagen i skemaet.', + 'Udfyld enten Start, Pause og Stop under de planlagte tider, eller skriv et tal i Plan timer.', + 'Klik Gem.', + 'I skemaet vises dagens plan med et kalenderikon.', + ], + detail: 'Planlagte skiftetider og Plan timer beskriver den samme plan. Udfylder du skiftetiderne, bliver Plan timer regnet ud for dig; skriver du i stedet et tal, går det hurtigere, når det præcise start- og stoptidspunkt ikke betyder noget.', + keywords: ['plan', 'plantimer', 'planlagte timer', 'vagtplan', 'norm', 'timer', 'ændre plan', 'skema'], + }, + 'task.payOutFlex': { + title: 'Udbetal flextimer', + short: 'Registrér en flexudbetaling på én enkelt dag, så timerne forlader flexsaldoen.', + steps: [ + 'Klik på den dag, udbetalingen hører til.', + 'Skriv antallet af timer i feltet Udbetalt flex.', + 'Klik Gem.', + 'I skemaet vises et udbetalingsikon med beløbet på dagen.', + ], + detail: 'Registrér udbetalingen på den dag, den blev aftalt eller udbetalt, i stedet for at fordele den over flere dage. Kig derefter på flexsaldoen på dagen og på dagene efter for at se, at resultatet er, som du forventer.', + keywords: ['flex', 'udbetaling', 'udbetalt flex', 'udbetal', 'afregne flex', 'løn', 'saldo', 'flexsaldo'], + }, + 'task.exportForPayroll': { + title: 'Få timerne ud til lønkørslen', + short: 'Hent perioden som en Excel-fil, for én medarbejder eller for alle.', + steps: [ + 'Klik på Excel-knappen i værktøjslinjen.', + 'Sæt datoerne for den periode, du har brug for.', + 'Lad Medarbejder stå tom for at få alle med, eller vælg én medarbejder.', + 'Klik på downloadknappen. Filen bygges på de datoer, du valgte i dialogen, ikke på den periode, skemaet viser.', + ], + detail: 'Datofelterne inde i downloaddialogen er deres egen indstilling. Ændrer du dem, ændrer det ikke, hvad skemaet viser, og skemaets periode ændrer ikke filen.', + keywords: ['eksport', 'excel', 'løn', 'lønkørsel', 'download', 'rapport', 'regneark', 'xlsx', 'timeseddel'], + }, + 'task.whoChangedThis': { + title: 'Se hvem der har ændret en dag', + short: 'Hver ændring på en dag bliver logget med tidspunkt, hvilket felt der blev ændret, den gamle og den nye værdi, og navnet på den, der ændrede den.', + steps: [ + 'Klik på dagen i skemaet for at åbne den.', + 'Klik på historikknappen ved siden af datoen øverst.', + 'Læs aktivitetsloggen — nyeste først, grupperet pr. dag.', + 'Klik Luk, når du er færdig.', + ], + detail: 'Brug den, før du retter en dag, du ikke selv har registreret. Den viser, om en værdi kom fra medarbejderens telefon eller blev tastet ind senere, og af hvem.', + keywords: ['historik', 'hvem har ændret', 'log', 'revision', 'aktivitet', 'ændringer', 'version', 'tidligere værdi'], + }, + 'task.whereWasThisRegistered': { + title: 'Se hvor en registrering blev lavet', + short: 'Har medarbejderens telefon gemt en position eller et billede sammen med en start, en pause eller et stop, kan du åbne det fra dagen.', + steps: [ + 'Klik på dagen i skemaet for at åbne den.', + 'Kig efter en nål eller et kameraikon ved siden af den registrerede tid, du undersøger.', + 'Klik på nålen for at se positionen på et kort, eller på kameraet for at se billedet.', + 'Luk sidepanelet, når du er færdig.', + ], + detail: 'Knapperne vises kun ved tider, der rent faktisk har en position eller et billede med. En tid registreret uden dem har ingen knap, og sådan skal det være.', + keywords: ['gps', 'position', 'kort', 'hvor', 'lokation', 'billede', 'foto', 'kamera', 'dokumentation'], + }, + 'task.filterToOneTeam': { + title: 'Vis kun ét hold', + short: 'Brug tags til at skære skemaet ned til den gruppe medarbejdere, du planlægger for.', + steps: [ + 'Åbn Tags i værktøjslinjen og vælg et eller flere tags.', + 'Skemaet viser nu kun medarbejdere med de tags.', + 'Du kan også klikke på et tag på en medarbejderrække for at filtrere på det.', + 'Ryd Tags-feltet for at se alle igen.', + ], + detail: 'Tags kommer fra den måde, jeres medarbejdere er inddelt på — hold, afdeling, sted. En medarbejder kan have flere tags og dukker op under dem alle.', + keywords: ['tag', 'tags', 'hold', 'gruppe', 'afdeling', 'filter', 'filtrer', 'team', 'kun mine folk'], + }, + + // ------------------------------------------------------- værktøjslinjen ---- + 'toolbar.showResigned': { + title: 'Vis fratrådt', + short: 'Skifter skemaet over til de medarbejdere, der er stoppet, så du stadig kan se deres timer.', + detail: 'Mens den er slået til, viser skemaet fratrådte medarbejdere i stedet for de nuværende. Slå den fra igen for at få din normale liste tilbage.', + keywords: ['fratrådt', 'stoppet', 'tidligere ansat', 'opsagt', 'inaktiv', 'gamle medarbejdere', 'ophørt'], + }, + 'toolbar.navBackward': { + title: 'Forrige periode', + short: 'Flytter skemaet en hel periode tilbage — lige så mange dage, som du ser nu.', + detail: 'Viser skemaet syv dage, går den syv dage tilbage. Periodens længde ændrer sig ikke; kun datoerne gør.', + keywords: ['tilbage', 'forrige', 'tidligere', 'sidste uge', 'gå tilbage', 'baglæns', 'foregående periode'], + }, + 'toolbar.navForward': { + title: 'Næste periode', + short: 'Flytter skemaet en hel periode frem — lige så mange dage, som du ser nu.', + detail: 'Brug den til at planlægge frem i tiden. Fremtidige dage viser kun planen; der er endnu ikke registreret noget på dem.', + keywords: ['frem', 'næste', 'senere', 'næste uge', 'fremad', 'kommende periode'], + }, + 'toolbar.workerFilter': { + title: 'Medarbejder', + short: 'Skærer skemaet ned til én medarbejder. Feltet vises kun, når der er mere end én at vælge imellem.', + detail: 'Ryd feltet for at få hele listen tilbage. Filteret er uafhængigt af tagfilteret — de to virker sammen.', + keywords: ['medarbejder', 'ansat', 'person', 'navn', 'én medarbejder', 'filter', 'kun én', 'site'], + }, + 'toolbar.tagFilter': { + title: 'Tags', + short: 'Skærer skemaet ned til de medarbejdere, der har de tags, du vælger. Du kan vælge flere ad gangen.', + detail: 'Et klik på et tag på en medarbejderrække gør det samme. Tøm feltet for at se alle igen.', + keywords: ['tags', 'tag', 'hold', 'gruppe', 'afdeling', 'kategori', 'etiket', 'filter'], + }, + 'toolbar.dateRange': { + title: 'Periode', + short: 'Bestemmer hvilke dage skemaet viser. Vælg en startdato og en slutdato.', + detail: 'Skemaet tegner én kolonne pr. dag i perioden, så en lang periode giver smalle kolonner. Pilene ved siden af feltet flytter hele perioden frem og tilbage med dens egen længde.', + keywords: ['dato', 'datoer', 'periode', 'uge', 'måned', 'fra', 'til', 'kalender', 'tidsrum'], + }, + 'toolbar.downloadExcel': { + title: 'Download Excel', + short: 'Åbner en dialog, der bygger en Excel-fil med timerne, for én medarbejder eller for alle.', + detail: 'Dialogen har sin egen periode, uafhængig af skemaet. Lader du Medarbejder stå tom, hedder knappen Download Excel (alle medarbejdere); vælger du en medarbejder, får du kun den ene med.', + keywords: ['excel', 'download', 'eksport', 'fil', 'regneark', 'rapport', 'xlsx', 'udskrift', 'løn'], + }, + 'toolbar.payrollExport': { + title: 'Eksporter til løn', + short: 'Åbner en dialog, der sender en hel lønperiode videre til det lønsystem, jeres virksomhed er sat op med.', + detail: 'Sæt en start- og en slutdato, så viser dialogen, hvor mange medarbejdere og hvor mange betalingslinjer perioden indeholder. Er en del af perioden eksporteret før, står det med dato, og knappen skifter til Eksporter alligevel.', + keywords: ['løn', 'lønsystem', 'eksport', 'lønperiode', 'betalingslinjer', 'send til løn', 'lønkørsel', 'overførsel'], + }, + 'toolbar.reload': { + title: 'Genindlæs tabel', + short: 'Henter skemaet igen, så registreringer, der er kommet ind fra telefoner imens, dukker op.', + detail: 'Skemaet opdaterer ikke sig selv. Brug knappen, når du venter på, at en medarbejder registrerer noget, eller hvis et tal ser forældet ud.', + keywords: ['genindlæs', 'opdater', 'hent igen', 'forældet', 'vises ikke', 'synkroniser', 'gammelt tal'], + }, + + // --------------------------------------------------------------- skemaet ---- + 'grid.nameColumn': { + title: 'Medarbejderkolonnen', + short: 'Skemaets første, fastlåste kolonne. Den viser medarbejderen, timerne for perioden, eventuelle tags og små ikoner for de regler, der gælder for den medarbejder.', + detail: 'Ringen om billedet fyldes op, efterhånden som medarbejderen kommer igennem de planlagte timer for den viste periode. Kolonnen bliver stående, mens du ruller dagene til siden.', + keywords: ['navn', 'medarbejder', 'ansat', 'første kolonne', 'venstre kolonne', 'billede', 'række', 'ring'], + }, + 'grid.tagChips': { + title: 'Tags på en medarbejderrække', + short: 'De små etiketter under medarbejderens navn. De viser, hvilke grupper medarbejderen hører til.', + detail: 'Klik på et tag for at filtrere skemaet på det — samme resultat som at vælge det i Tags-feltet i værktøjslinjen.', + keywords: ['tag', 'tags', 'etiket', 'mærkat', 'hold', 'gruppe', 'afdeling', 'filtrer på tag'], + }, + 'grid.settingsStrip': { + title: 'Ikonerne under medarbejderens navn', + short: 'En række små ikoner, der viser hvilke regler der gælder for medarbejderen. Et tændt ikon betyder, at reglen er slået til; et nedtonet betyder, at den er slået fra.', + detail: 'Fra venstre: kr er det lønregelsæt, der bruges, det næste ikon er måden medarbejderen registrerer tid på i appen, månen betyder at vagter må løbe over midnat, de to streger betyder at pauser beregnes automatisk, 1m betyder at tider vælges i trin på ét minut i stedet for fem, og 3v betyder at medarbejderen har skift 3, 4 eller 5 slået til. Hold musen over et ikon for at læse, hvad der står.', + keywords: ['ikoner', 'indstillinger', 'regler', 'symboler', 'kr', '1m', '3v', 'lønregel', 'automatisk pause', 'midnat'], + }, + 'grid.dayCellAnatomy': { + title: 'Hvad et dagfelt viser', + short: 'Ét felt pr. medarbejder pr. dag, læst oppefra og ned: hvad der var planlagt, hvad der blev registreret, og hvad dagen endte med at tælle som.', + detail: 'Kalenderikonet markerer de planlagte skiftetider. Pil ind og pil ud markerer den registrerede start og det registrerede stop; en advarselstrekant i stedet for et stop betyder, at vagten aldrig blev stoppet. Derunder står den samlede pause, de timer dagen tæller som, en eventuel udbetalt flex og flexsaldoen til og med dagen, i rødt når den er negativ. Til sidst kommer kommentarerne: et ansigtsikon for medarbejderens egen kommentar, et husikon for kontorets. Totaler vises kun på i dag og på dage, der er gået.', + keywords: ['dagfelt', 'felt', 'dag', 'kolonne', 'læse skemaet', 'ikoner', 'betydning', 'hvad betyder', 'symboler', 'opbygning'], + }, + 'grid.weeklyPlannedHours': { + title: 'Timerne i medarbejderkolonnen', + short: 'Timerne ved siden af medarbejderens navn er totalen for hele den viste periode — først de planlagte, derefter de registrerede i parentes.', + detail: 'Forveksl dem ikke med timerne inde i et enkelt dagfelt, som kun gælder den ene dag. Ændrer du perioden, ændrer totalen sig med, fordi den altid dækker de viste dage.', + keywords: ['total', 'sum', 'ugetimer', 'uge', 'planlagte timer', 'registrerede timer', 'periodetotal', 'parentes'], + }, + 'grid.messageIcons': { + title: 'Dagtypeikoner i et dagfelt', + short: 'Ikonet i højre kant af et dagfelt viser den dagtype, der er sat på dagen — ferie, sygdom, kursus og så videre.', + detail: 'Hold musen over ikonet for at læse, hvilken type det er. Sygedage og barns sygedage vises i rødt; resten i blåt. En dag uden dagtype har intet ikon.', + keywords: ['ikon', 'symbol', 'ferieikon', 'sygeikon', 'fly', 'dagtype', 'fravær', 'hvad er det for et ikon', 'farve'], + }, + 'grid.sortName': { + title: 'Sortering på navn', + short: 'Klik på overskriften Navn for at sortere medarbejderne, og klik igen for at vende rækkefølgen om.', + detail: 'Kun medarbejderkolonnen kan sorteres. Dagkolonnerne står altid i datorækkefølge.', + keywords: ['sorter', 'sortering', 'rækkefølge', 'alfabetisk', 'a-å', 'navn', 'omvendt'], + }, + 'grid.openDay': { + title: 'Sådan åbner du en dag', + short: 'Klik et vilkårligt sted i et dagfelt for at åbne dagen til redigering.', + detail: 'Dialogen, der åbner, dækker én medarbejder på én dato. Alt på dagen — plan, registrerede tider, dagtype og kommentarer — redigeres der og træder i kraft, når du gemmer.', + keywords: ['åbn', 'klik', 'rediger dag', 'dagdialog', 'ændre dag', 'dobbeltklik', 'hvordan retter jeg en dag'], + }, + + // ---------------------------------------------------------- rediger dag ---- + 'dayCell.versionHistory': { + title: 'Historikknappen', + short: 'Åbner aktivitetsloggen for dagen — hver ændring, nyeste først, med tidspunkt, hvad der blev ændret, fra hvad til hvad, og hvem der gjorde det.', + detail: 'Hvor en ændring har en position eller et billede med, giver loggen dig et link til at åbne det. Brug loggen, før du retter en dag, du ikke selv har registreret.', + keywords: ['historik', 'log', 'aktivitet', 'revision', 'hvem har ændret', 'ændringer', 'version', 'spor'], + }, + 'dayCell.plannedTimes': { + title: 'Planlagte tider', + short: 'Start, pause og stop for det, medarbejderen er sat til på dagen. Ét sæt felter pr. skift.', + detail: 'Udfylder du dem, bliver Plan timer regnet om for dagen. Pausen kan ikke være længere end afstanden mellem start og stop. I skemaet vises planlagte tider med et kalenderikon.', + keywords: ['planlagt', 'plan', 'vagtplan', 'forventet', 'start', 'stop', 'pause', 'skiftetider', 'vagt'], + }, + 'dayCell.actualTimes': { + title: 'Registrerede tider', + short: 'Start, pause og stop, sådan som de faktisk blev registreret på dagen. Det er dem, der afgør, hvad dagen tæller som.', + detail: 'En tid her kommer som regel fra medarbejderens telefon, men du kan taste en ind eller rette en. På en fremtidig dag findes der ingen registrerede tider.', + keywords: ['faktisk', 'registreret', 'arbejdet', 'stemplet ind', 'start', 'stop', 'pause', 'optaget tid'], + }, + 'dayCell.shiftCount': { + title: 'Hvor mange skift en dag har', + short: 'Skift 1 og skift 2 er altid til rådighed. Skift 3, 4 og 5 vises kun for medarbejdere, der er sat op med dem.', + detail: 'Mærket 3v i medarbejderrækken fortæller, hvilke ekstra skift en medarbejder har slået til. Hold musen over det for at se, om det er skift 3, 4, 5 eller en kombination.', + keywords: ['skift', 'vagter', 'andet skift', 'tredje skift', 'fjerde', 'femte', 'ekstra skift', 'delt vagt', '3v'], + }, + 'dayCell.resetField': { + title: 'Tøm et tidsfelt', + short: 'Skraldespanden ved siden af et tidsfelt rydder feltet.', + detail: 'At rydde et felt er ikke det samme som at sætte tiden til 00:00. Brug den, når en værdi slet ikke skal være der, og gem derefter.', + keywords: ['ryd', 'slet', 'tøm', 'fjern', 'skraldespand', 'nulstil', 'fortryd', 'tomt felt'], + }, + 'dayCell.resetPauseToRecorded': { + title: 'Nulstil pause til det registrerede', + short: 'Sætter pausen tilbage til den længde, der faktisk blev registreret, og fortryder dermed en pause, du selv har tastet ind.', + detail: 'Feltet viser med det samme den registrerede længde, så du kan se, hvad du går tilbage til, før du gemmer. Er det ikke det, du vil, så vælg en anden pause i stedet for at gemme.', + keywords: ['pause', 'nulstil', 'gendan', 'registreret pause', 'oprindelig', 'fortryd pause', 'tilbage til registreret'], + }, + 'dayCell.gps': { + title: 'Positionsknappen', + short: 'Åbner den position, der blev gemt sammen med denne start, pause eller dette stop, på et kort ved siden af dagen.', + detail: 'Knappen findes kun ved tider, der rent faktisk har en position med. Luk sidepanelet for at få dialogen tilbage i normal bredde.', + keywords: ['gps', 'position', 'kort', 'lokation', 'nål', 'hvor', 'koordinater', 'adresse'], + }, + 'dayCell.snapshot': { + title: 'Billedknappen', + short: 'Åbner det billede, der blev taget sammen med denne start, pause eller dette stop, ved siden af dagen.', + detail: 'Knappen findes kun ved tider, der rent faktisk har et billede med. Luk sidepanelet for at få dialogen tilbage i normal bredde.', + keywords: ['billede', 'foto', 'kamera', 'snapshot', 'selfie', 'dokumentation', 'bevis'], + }, + 'dayCell.futureDisabled': { + title: 'Dage ude i fremtiden', + short: 'På en dag, der endnu ikke er indtruffet, kan du sætte planen, mens de registrerede tider og flexfelterne ikke er tilgængelige.', + detail: 'Sådan skal det være, og det er ikke en fejl: der er ikke registreret noget på en fremtidig dag, og der er ingen saldo at vise for den. Læg planen nu, og kom tilbage efter datoen for at se, hvad der blev registreret.', + keywords: ['fremtid', 'nedtonet', 'deaktiveret', 'kan ikke rette', 'låst', 'skrivebeskyttet', 'i morgen', 'næste uge'], + }, + 'dayCell.planHours': { + title: 'Plan timer', + short: 'Det antal timer, medarbejderen er planlagt til på dagen, som et rent tal.', + detail: 'Udfylder du de planlagte skiftetider, bliver tallet regnet ud for dig; skriver du det selv, går det hurtigere, når det præcise start- og stoptidspunkt ikke betyder noget. Summen for alle skift på én dag kan ikke overstige 24.', + keywords: ['plan timer', 'plantimer', 'planlagt', 'timer', 'norm', 'mål', 'antal timer'], + }, + 'dayCell.nettoOverride': { + title: 'Netto timer overskrivning', + short: 'Hvad dagen skal tælle som, når det ikke skal være de timer, der blev registreret. Feltet vises kun, når der er sat en overskrivning på dagen.', + detail: 'Sætter du en dagtype, udfyldes feltet for dig: Fridag og Afspadsering sætter det til nul, og alle andre dagtyper sætter det til de timer, der var planlagt for dagen. Du kan også skrive en værdi selv. Skemaet viser så dette tal som dagens timer i stedet for den registrerede total.', + keywords: ['netto', 'nettotimer', 'overskrivning', 'tæller som', 'korrektion', 'rettelse', 'manuelle timer', 'fast timetal'], + }, + 'dayCell.paidOutFlex': { + title: 'Udbetalt flex', + short: 'Det antal flextimer, der er udbetalt på denne dag.', + detail: 'Registrér en udbetaling på den dag, den hører til. Skemaet viser så et udbetalingsikon med beløbet i dagfeltet. Kig på flexsaldoen på dagen og på dagene efter for at se, at resultatet er, som du forventer.', + keywords: ['flex', 'udbetalt', 'udbetaling', 'udbetal', 'afregning', 'løn', 'flexsaldo', 'hævet'], + }, + 'dayCell.flags': { + title: 'Dagtype', + short: 'Markerer hvad det er for en slags dag — ferie, sygdom, kursus og så videre. De ligner afkrydsningsfelter, men en dag har kun én dagtype ad gangen: sætter du et nyt flueben, forsvinder det forrige.', + detail: 'Fluebenet bestemmer også, hvad dagen tæller som. Fridag og Afspadsering sætter dagen til nul timer. Ferie, Syg, Kursus, Orlov, Barns 1. sygedag, Barns 2. sygedag, Ferie fridag, Barselsorlov, Helligdag og Graviditetsbetinget fravær sætter den derimod til de timer, der var planlagt for dagen. Fridag og Ferie står lige ved siden af hinanden øverst i listen og gør det stik modsatte af hinanden, så vælg den, der passer til, hvad dagen skal tælle som. Vær også opmærksom på Ferie fridag: den lyder som en fridag, men opfører sig som Ferie og beholder de planlagte timer. Fjerner du fluebenet igen, forsvinder den indstilling.', + keywords: ['dagtype', 'ferie', 'sygdom', 'syg', 'kursus', 'barsel', 'orlov', 'helligdag', 'afspadsering', 'fridag', 'feriefridag', 'fravær', 'flueben', 'markér dag'], + }, + 'dayCell.commentOffice': { + title: 'Kommentar kontor', + short: 'En note, du skriver på dagen. Den vises i skemaet på den dag med et husikon.', + detail: 'Brug den til at skrive, hvorfor en dag blev ændret — en telefon glemt hjemme, en vagt aftalt over telefonen. Medarbejderens egen kommentar står for sig selv med et ansigtsikon og kan ikke rettes her.', + keywords: ['kommentar', 'note', 'bemærkning', 'kontor', 'besked', 'hvorfor', 'forklaring', 'tekst'], + }, + 'dayCell.save': { + title: 'Gem', + short: 'Skriver dagen og lukker dialogen. Skemaet henter derefter data igen og fremhæver den dag, du ændrede.', + detail: 'Knappen er slået fra, så længe noget på dagen ikke er gyldigt — det felt, det handler om, viser årsagen med rødt. Annullér lukker uden at skrive noget.', + keywords: ['gem', 'ok', 'godkend', 'bekræft', 'indsend', 'grå knap', 'kan ikke gemme', 'annullér'], + }, + 'dayCell.oneMinuteIntervals': { + title: 'Trin på ét minut', + short: 'Tidsvælgerne går i trin på fem minutter for de fleste medarbejdere, og i trin på ét minut for medarbejdere, der er sat op til det.', + detail: 'Mærket 1m i medarbejderrækken fortæller, hvad der gælder. Kan du ikke ramme det præcise minut i en tidsvælger, er den medarbejder sat til trin på fem minutter.', + keywords: ['minutter', 'ét minut', '1m', 'fem minutter', 'interval', 'trin', 'afrunding', 'præcist klokkeslæt', 'tidsvælger'], + }, + + // ------------------------------------------------------------------ flex ---- + 'flex.whatIsFlex': { + title: 'Hvad flex er', + short: 'Flex er forskellen mellem det, medarbejderen var planlagt til, og det dagen endte med at tælle som. En lang dag bygger flex op; en kort dag trækker den ned.', + detail: 'Dagdialogen viser flexen for den ene dag i sit eget felt. Skemaet viser i stedet den løbende saldo, så de to tal er ikke det samme.', + keywords: ['flex', 'flextid', 'overarbejde', 'afspadsering', 'timebank', 'plustimer', 'minustimer', 'hvad er flex'], + }, + 'flex.sumFlex': { + title: 'Flexsaldo', + short: 'Den løbende flexsaldo. Dagdialogen viser den både ved dagens begyndelse og til og med dagen; skemaet viser saldoen for hver enkelt dag.', + detail: 'En negativ saldo vises med rødt. Vil du se, hvordan en saldo er opstået, så gå dagene inden igennem og læs flexlinjen i hver af dem — der kan du se, hvor den flyttede sig.', + keywords: ['flexsaldo', 'saldo', 'sum', 'flex sum', 'samlet flex', 'løbende', 'negativ', 'rød', 'minus', 'til gode'], + }, + 'flex.paidOutFlexRelation': { + title: 'Flex og udbetalinger', + short: 'Udbetalte timer registreres på én dag og vises som en udbetalingslinje i det dagfelt. Saldoen fra den dag og frem er den, siden har gemt for medarbejderen.', + detail: 'Ser en saldo ikke ud, som du forventer efter en udbetaling, så åbn den dag, udbetalingen blev registreret på, kontrollér beløbet i feltet Udbetalt flex, og læs aktivitetsloggen for dagen. Det er der, svaret ligger.', + keywords: ['udbetalt', 'udbetaling', 'flex', 'saldo', 'stemmer ikke', 'forkert saldo', 'mangler timer', 'afregnet', 'trukket fra'], + }, +}; + +export const daUi: HelpUiStrings = { + help: 'Hjælp', + searchHelp: 'Søg i hjælp', + clear: 'Ryd', + close: 'Luk', + moreInHelp: 'Mere i hjælpen', + replayTour: 'Tag rundvisningen', + skip: 'Spring over', + next: 'Næste', + noResults: 'Ingen træffere. Her er det, folk oftest har brug for:', + sectionTask: 'Almindelige opgaver', + sectionToolbar: 'Værktøjslinje', + sectionGrid: 'Skemaet', + sectionDayCell: 'Rediger dag', + sectionFlex: 'Flex', +}; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts index 41228252..6be0b5b9 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts @@ -1,13 +1,16 @@ import { HelpProseMap, HelpUiStrings } from '../help.model'; import { enUS, enUSUi } from './enUS'; +import { da, daUi } from './da'; /** Locale code (as ngx-translate reports it) to prose. Partial maps fall back per entry. */ export const HELP_LOCALES: Record> = { 'en-US': enUS, + 'da': da, }; export const HELP_UI_LOCALES: Record = { 'en-US': enUSUi, + 'da': daUi, }; export const HELP_FALLBACK: HelpProseMap = enUS; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts index f7ae7d15..2c8d94de 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts @@ -2,6 +2,9 @@ import { TestBed } from '@angular/core/testing'; import { TranslateService } from '@ngx-translate/core'; import { HelpContentService } from './help-content.service'; import { enUS } from '../i18n/enUS'; +import { da } from '../i18n/da'; +import { HELP_LOCALES } from '../i18n'; +import { HelpProseMap } from '../help.model'; describe('HelpContentService', () => { let translate: { currentLang: string }; @@ -32,7 +35,24 @@ describe('HelpContentService', () => { it('resolves a bare language code to its locale file', () => { const service = make('da'); - expect(service.prose('toolbar.dateRange')).toBeDefined(); + expect(service.prose('toolbar.dateRange')).toEqual(da['toolbar.dateRange']); + }); + + // prose() falls back to English one entry at a time, not one locale at a time. + // A locale map that is present but missing a single id must still serve its own + // language for every other id. + it('falls back to English only for the id a locale map is missing', () => { + const partial: Partial = { ...da }; + delete partial['toolbar.dateRange']; + HELP_LOCALES['da-partial'] = partial; + try { + const service = make('da-partial'); + expect(service.prose('toolbar.dateRange')).toEqual(enUS['toolbar.dateRange']); + expect(service.prose('toolbar.reload')).toEqual(da['toolbar.reload']); + expect(service.prose('dayCell.flags')).toEqual(da['dayCell.flags']); + } finally { + delete HELP_LOCALES['da-partial']; + } }); it('hides admin-only entries from a non-admin', () => { From ab543bbcf291f6fc004427089df5597d76ca2fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:50:09 +0200 Subject: [PATCH 11/38] =?UTF-8?q?docs:=20correct=20the=20day-flag=20hazard?= =?UTF-8?q?=20=E2=80=94=20it=20is=20naming,=20not=20adjacency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I had written that Vacation and Vacation day off "sit next to each other and produce opposite results". They do not: in render order Vacation is 2nd and Vacation day off is 10th, with Time off, Maternity and the two children's-sick-day types between them. The confusion that actually bites is name versus behaviour. Time off keeps the day's planned hours while the similarly-named Day off and Vacation day off zero them. Danish is worse: the shipped labels are Fridag (zero), Afspadsering (zero) and Ferie fridag — which keeps the hours despite being named a fridag. Found by the Task 3 implementer while translating, and verified against TimePlanningMessagesEnum and both shipped label maps. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../2026-09-04-planning-help-system-design.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md index 4889f69b..82359c72 100644 --- a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md +++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md @@ -233,9 +233,19 @@ rest — and ticking one rewrites netto hours | `DayOff`, `VacationDayOff` | `0` | | `Vacation`, `Sick`, `Course`, `LeaveOfAbsence`, `Maternity`, `Holiday`, and the rest | the day's planned hours | -So `Vacation` and `VacationDayOff` sit next to each other and produce opposite results. -`registerVacation` and `registerDayOff` must state which one counts as worked time; -this is the single most valuable thing the help system can say. +The trap is **name versus behaviour**, not adjacency. In render order the types are +`DayOff`, `Vacation`, `Sick`, `Course`, `LeaveOfAbsence`, `Children1stSick`, +`Children2stSick`, `TimeOff`, `Maternity`, `VacationDayOff`, `Holiday`, +`PregnancyLeave` — so `Vacation` (2nd) and `VacationDayOff` (10th) are nowhere near +each other. What actually catches people is that **`TimeOff` keeps the planned hours** +while the similarly-named `DayOff` and `VacationDayOff` zero them. + +It is worse in Danish, where the shipped labels are `Fridag` (DayOff, zero), +`Afspadsering` (VacationDayOff, zero) and **`Ferie fridag` (TimeOff, keeps the hours +despite being named a fridag)**. + +`registerVacation`, `registerDayOff` and `dayCell.flags` must state which types count +as worked time; this is the single most valuable thing the help system can say. The full flag set is `TimePlanningMessagesEnum`: `DayOff`, `Vacation`, `Sick`, `Course`, `LeaveOfAbsence`, `Children1stSick`, `Children2stSick`, `TimeOff`, From d9c488378fa60c4e551ae4259d471a46296967c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:52:07 +0200 Subject: [PATCH 12/38] fix(help): correct the day-type warning in both locales The English day-type entry claimed Vacation and Vacation day off sit next to each other. In render order Vacation is 2nd and Vacation day off is 10th, so the claim is false; the Danish carried the same framing. Adjacency was never the trap anyway. Time off (da: Ferie fridag) is named like the two types that zero the day but behaves like the ones that keep the planned hours. Both locales now say that plainly, in the day-type entry, the day-off task and the netto override entry. day-type-copy.spec.ts walks HELP_LOCALES so neither the false claim nor a missing warning can come back in any locale. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../modules/time-planning-pn/help/i18n/da.ts | 6 +- .../help/i18n/day-type-copy.spec.ts | 74 +++++++++++++++++++ .../time-planning-pn/help/i18n/enUS.ts | 8 +- 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts index a9067156..07fdb257 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts @@ -38,7 +38,7 @@ export const da: HelpProseMap = { 'Sæt flueben ved Fridag, eller ved Afspadsering hvis dagen er aftalt som afspadsering.', 'Klik Gem. Dagen tæller nu som nul timer.', ], - detail: 'En dag har kun én dagtype ad gangen — et flueben ved Fridag eller Afspadsering fjerner den type, der stod før. De to sætter begge dagen til nul timer, mens Ferie, Syg, Kursus og de øvrige dagtyper beholder de planlagte timer. Det er den forskel, man skal holde øje med.', + detail: 'En dag har kun én dagtype ad gangen — et flueben ved Fridag eller Afspadsering fjerner den type, der stod før. De to sætter begge dagen til nul timer, mens Ferie, Syg, Kursus og de øvrige dagtyper beholder de planlagte timer. Vær især opmærksom på Ferie fridag: trods navnet beholder den de planlagte timer i stedet for at sætte dagen til nul timer.', keywords: ['fridag', 'fri', 'afspadsering', 'afspadsere', 'nul timer', 'ikke på arbejde', 'hjemme', 'holder fri'], }, 'task.correctRegisteredTime': { @@ -320,7 +320,7 @@ export const da: HelpProseMap = { 'dayCell.nettoOverride': { title: 'Netto timer overskrivning', short: 'Hvad dagen skal tælle som, når det ikke skal være de timer, der blev registreret. Feltet vises kun, når der er sat en overskrivning på dagen.', - detail: 'Sætter du en dagtype, udfyldes feltet for dig: Fridag og Afspadsering sætter det til nul, og alle andre dagtyper sætter det til de timer, der var planlagt for dagen. Du kan også skrive en værdi selv. Skemaet viser så dette tal som dagens timer i stedet for den registrerede total.', + detail: 'Sætter du en dagtype, udfyldes feltet for dig: Fridag og Afspadsering sætter det til nul, og alle andre dagtyper — også Ferie fridag, trods navnet — sætter det til de timer, der var planlagt for dagen. Du kan også skrive en værdi selv. Skemaet viser så dette tal som dagens timer i stedet for den registrerede total.', keywords: ['netto', 'nettotimer', 'overskrivning', 'tæller som', 'korrektion', 'rettelse', 'manuelle timer', 'fast timetal'], }, 'dayCell.paidOutFlex': { @@ -332,7 +332,7 @@ export const da: HelpProseMap = { 'dayCell.flags': { title: 'Dagtype', short: 'Markerer hvad det er for en slags dag — ferie, sygdom, kursus og så videre. De ligner afkrydsningsfelter, men en dag har kun én dagtype ad gangen: sætter du et nyt flueben, forsvinder det forrige.', - detail: 'Fluebenet bestemmer også, hvad dagen tæller som. Fridag og Afspadsering sætter dagen til nul timer. Ferie, Syg, Kursus, Orlov, Barns 1. sygedag, Barns 2. sygedag, Ferie fridag, Barselsorlov, Helligdag og Graviditetsbetinget fravær sætter den derimod til de timer, der var planlagt for dagen. Fridag og Ferie står lige ved siden af hinanden øverst i listen og gør det stik modsatte af hinanden, så vælg den, der passer til, hvad dagen skal tælle som. Vær også opmærksom på Ferie fridag: den lyder som en fridag, men opfører sig som Ferie og beholder de planlagte timer. Fjerner du fluebenet igen, forsvinder den indstilling.', + detail: 'Fluebenet bestemmer også, hvad dagen tæller som. Fridag og Afspadsering sætter dagen til nul timer. Ferie, Syg, Kursus, Orlov, Barns 1. sygedag, Barns 2. sygedag, Ferie fridag, Barselsorlov, Helligdag og Graviditetsbetinget fravær sætter den derimod til de timer, der var planlagt for dagen. Hold især øje med navnene: Ferie fridag lyder som Fridag og Afspadsering, men gør det modsatte af dem begge — den beholder de planlagte timer, hvor de to sætter dagen til nul timer. Vælg den type, der passer til, hvad dagen skal tælle som. Fjerner du fluebenet igen, forsvinder den indstilling.', keywords: ['dagtype', 'ferie', 'sygdom', 'syg', 'kursus', 'barsel', 'orlov', 'helligdag', 'afspadsering', 'fridag', 'feriefridag', 'fravær', 'flueben', 'markér dag'], }, 'dayCell.commentOffice': { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts new file mode 100644 index 00000000..a7a0668e --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts @@ -0,0 +1,74 @@ +import { HELP_LOCALES } from './index'; +import { HelpProse } from '../help.model'; + +/** + * The day-type checkboxes are the highest-value thing the help explains, and the two + * ways to get the copy wrong are the same in every language: + * + * 1. Claiming the two opposite types sit next to each other. They do not — the render + * order is Day off, Vacation, Sick, Course, Leave of absence, Children 1st sick day, + * Children 2st sick day, Time off, Maternity leave, Vacation day off, Holiday, + * Pregnancy-related absence, so Vacation is 2nd and Vacation day off is 10th. + * 2. Leaving out the confusion that actually bites: one type is named like the two that + * zero the day, but behaves like the ones that keep the planned hours. + * + * These rules hold for every locale, so this spec walks the registered locales rather + * than one content file. + */ +describe('day type copy in every locale', () => { + const ADJACENCY = /next to each other|side by side|adjacent|ved siden af hinanden/i; + + interface LocaleExpectation { + /** The type that keeps the planned hours despite being named like a day off. */ + lookAlike: string; + /** The two types that rewrite the day to zero hours. */ + zeroing: [string, string]; + /** How this language says "the hours planned for the day". */ + keepsPlanned: RegExp; + /** How this language says "zero hours". */ + zeroHours: RegExp; + } + + const EXPECTED: Record = { + 'en-US': { + lookAlike: 'Time off', + zeroing: ['Day off', 'Vacation day off'], + keepsPlanned: /planned/i, + zeroHours: /zero hours/i, + }, + da: { + lookAlike: 'Ferie fridag', + zeroing: ['Fridag', 'Afspadsering'], + keepsPlanned: /planlagt/i, + zeroHours: /nul timer/i, + }, + }; + + const flagsText = (locale: string): string => { + const prose = HELP_LOCALES[locale]?.['dayCell.flags'] as HelpProse | undefined; + expect(prose).toBeDefined(); + return [(prose as HelpProse).short, (prose as HelpProse).detail ?? ''].join(' '); + }; + + for (const [locale, expected] of Object.entries(EXPECTED)) { + it(`${locale} never claims the opposite day types sit next to each other`, () => { + expect(flagsText(locale)).not.toMatch(ADJACENCY); + }); + + it(`${locale} warns that ${expected.lookAlike} keeps the planned hours despite its name`, () => { + const text = flagsText(locale); + expect(text).toContain(expected.lookAlike); + for (const zeroing of expected.zeroing) { + expect(text).toContain(zeroing); + } + expect(text).toMatch(expected.keepsPlanned); + expect(text).toMatch(expected.zeroHours); + }); + } + + it('registers a locale for every expectation above', () => { + for (const locale of Object.keys(EXPECTED)) { + expect(HELP_LOCALES[locale]).toBeDefined(); + } + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts index 133121ac..a96ff856 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts @@ -11,7 +11,7 @@ export const enUS: HelpProseMap = { 'Click Save. The day now counts as the planned hours.', 'Repeat for each vacation day.', ], - detail: 'A day carries one day type at a time — ticking Vacation clears any other type already set. Use Vacation day off instead if the day should count as zero hours.', + detail: 'A day carries one day type at a time — ticking Vacation clears any other type already set. Use Day off or Vacation day off instead if the day should count as zero hours.', keywords: ['vacation', 'holiday', 'time off', 'leave', 'absent', 'away', 'ferie', 'annual leave'], }, 'task.registerSickness': { @@ -34,7 +34,7 @@ export const enUS: HelpProseMap = { 'Tick Day off, or Vacation day off if it comes out of the vacation balance.', 'Click Save. The day now counts as zero hours.', ], - detail: 'A day carries one day type at a time — ticking Day off or Vacation day off clears any other type already set. Both of them set the day to zero hours, while Vacation, sickness, course and the other day types keep the planned hours instead. This is the difference to watch for.', + detail: 'A day carries one day type at a time — ticking Day off or Vacation day off clears any other type already set. Both of them set the day to zero hours, while Vacation, sickness, course and the other day types keep the planned hours instead. Time off is the one to watch: despite the name it keeps the planned hours, like Vacation, rather than zeroing the day.', keywords: ['day off', 'off', 'free', 'not working', 'zero hours', 'vacation day off', 'rest day'], }, 'task.correctRegisteredTime': { @@ -316,7 +316,7 @@ export const enUS: HelpProseMap = { 'dayCell.nettoOverride': { title: 'Netto hours override', short: 'What the day counts as, when it should not be the hours that were registered. The field appears only when an override is in force on the day.', - detail: 'Ticking a day type sets this for you: Day off and Vacation day off set it to zero, and every other type sets it to the hours planned for the day. You can also type a value in yourself. The grid then shows this figure as the day\'s hours instead of the registered total.', + detail: 'Ticking a day type sets this for you: Day off and Vacation day off set it to zero, and every other type — Time off included, despite its name — sets it to the hours planned for the day. You can also type a value in yourself. The grid then shows this figure as the day\'s hours instead of the registered total.', keywords: ['netto', 'override', 'counts as', 'adjust', 'correction', 'manual hours', 'net hours', 'force', 'set hours'], }, 'dayCell.paidOutFlex': { @@ -328,7 +328,7 @@ export const enUS: HelpProseMap = { 'dayCell.flags': { title: 'Day type', short: 'Marks what kind of day this is — vacation, sickness, course and so on. They look like checkboxes, but a day carries only one type at a time: ticking a new one unticks the previous one.', - detail: 'Ticking a type also sets what the day counts as. Day off and Vacation day off set it to zero hours. Vacation, Sick, Course, Leave of absence, Children 1st sick day, Children 2st sick day, Time off, Maternity leave, Holiday and Pregnancy-related absence all set it to the hours planned for that day. So Vacation and Vacation day off sit next to each other and do the opposite of one another — pick the one that matches what the day should count as. Unticking the type again removes that setting.', + detail: 'Ticking a type also sets what the day counts as. Day off and Vacation day off set it to zero hours. Vacation, Sick, Course, Leave of absence, Children 1st sick day, Children 2st sick day, Time off, Maternity leave, Holiday and Pregnancy-related absence all set it to the hours planned for that day. Watch the names: Time off reads like Day off and Vacation day off, but it does the opposite of both — it keeps the planned hours where they set the day to zero. Pick the type that matches what the day should count as. Unticking the type again removes that setting.', keywords: ['day type', 'vacation', 'sickness', 'sick', 'course', 'maternity', 'leave', 'holiday', 'flag', 'absence', 'checkbox', 'day off', 'time off', 'mark day'], }, 'dayCell.commentOffice': { From 72362eb1ba6dbc1179eea437da13be2040056926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 13:59:12 +0200 Subject: [PATCH 13/38] test(help): make the day-type warning assertion non-vacuous The warning half of day-type-copy.spec.ts checked that 'Time off', 'Day off', 'Vacation day off', /planned/i and /zero hours/i each appeared somewhere in the entry. The ordinary type-by-type description names every type and both outcomes anyway, so all five held with the warning sentence deleted - the exact regression the test was meant to catch. It now requires the look-alike type's name, a despite-the-name construction, both zeroing types and both outcomes in the SAME sentence. The bare co-occurrence checks stay, under an honest name. Both locales' dayCell.flags move onto the despite-the-name construction that task.registerDayOff and dayCell.nettoOverride already used, so all three sites phrase the trap the same way. Mutation-checked: deleting only the warning sentence from enUS.ts turns that one test red and leaves the other six green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../modules/time-planning-pn/help/i18n/da.ts | 2 +- .../help/i18n/day-type-copy.spec.ts | 31 +++++++++++++++++-- .../time-planning-pn/help/i18n/enUS.ts | 2 +- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts index 07fdb257..40a10190 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts @@ -332,7 +332,7 @@ export const da: HelpProseMap = { 'dayCell.flags': { title: 'Dagtype', short: 'Markerer hvad det er for en slags dag — ferie, sygdom, kursus og så videre. De ligner afkrydsningsfelter, men en dag har kun én dagtype ad gangen: sætter du et nyt flueben, forsvinder det forrige.', - detail: 'Fluebenet bestemmer også, hvad dagen tæller som. Fridag og Afspadsering sætter dagen til nul timer. Ferie, Syg, Kursus, Orlov, Barns 1. sygedag, Barns 2. sygedag, Ferie fridag, Barselsorlov, Helligdag og Graviditetsbetinget fravær sætter den derimod til de timer, der var planlagt for dagen. Hold især øje med navnene: Ferie fridag lyder som Fridag og Afspadsering, men gør det modsatte af dem begge — den beholder de planlagte timer, hvor de to sætter dagen til nul timer. Vælg den type, der passer til, hvad dagen skal tælle som. Fjerner du fluebenet igen, forsvinder den indstilling.', + detail: 'Fluebenet bestemmer også, hvad dagen tæller som. Fridag og Afspadsering sætter dagen til nul timer. Ferie, Syg, Kursus, Orlov, Barns 1. sygedag, Barns 2. sygedag, Ferie fridag, Barselsorlov, Helligdag og Graviditetsbetinget fravær sætter den derimod til de timer, der var planlagt for dagen. Hold især øje med navnene: Ferie fridag lyder som Fridag og Afspadsering, men beholder trods navnet de planlagte timer, hvor de to sætter dagen til nul timer. Vælg den type, der passer til, hvad dagen skal tælle som. Fjerner du fluebenet igen, forsvinder den indstilling.', keywords: ['dagtype', 'ferie', 'sygdom', 'syg', 'kursus', 'barsel', 'orlov', 'helligdag', 'afspadsering', 'fridag', 'feriefridag', 'fravær', 'flueben', 'markér dag'], }, 'dayCell.commentOffice': { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts index a7a0668e..ce142c67 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts @@ -23,6 +23,8 @@ describe('day type copy in every locale', () => { lookAlike: string; /** The two types that rewrite the day to zero hours. */ zeroing: [string, string]; + /** This language's despite-the-name construction. */ + nameTrap: RegExp; /** How this language says "the hours planned for the day". */ keepsPlanned: RegExp; /** How this language says "zero hours". */ @@ -33,12 +35,14 @@ describe('day type copy in every locale', () => { 'en-US': { lookAlike: 'Time off', zeroing: ['Day off', 'Vacation day off'], + nameTrap: /despite (the|its) name/i, keepsPlanned: /planned/i, zeroHours: /zero hours/i, }, da: { lookAlike: 'Ferie fridag', zeroing: ['Fridag', 'Afspadsering'], + nameTrap: /trods navnet/i, keepsPlanned: /planlagt/i, zeroHours: /nul timer/i, }, @@ -50,20 +54,41 @@ describe('day type copy in every locale', () => { return [(prose as HelpProse).short, (prose as HelpProse).detail ?? ''].join(' '); }; + // Terminator plus a capitalised next word, so the abbreviated ordinals inside + // "Barns 1. sygedag" do not split a Danish sentence in two. + const sentences = (text: string): string[] => text.split(/(?<=[.!?])\s+(?=[A-ZÆØÅ])/); + for (const [locale, expected] of Object.entries(EXPECTED)) { it(`${locale} never claims the opposite day types sit next to each other`, () => { expect(flagsText(locale)).not.toMatch(ADJACENCY); }); - it(`${locale} warns that ${expected.lookAlike} keeps the planned hours despite its name`, () => { + it(`${locale} names both day types that set the day to zero hours`, () => { const text = flagsText(locale); - expect(text).toContain(expected.lookAlike); for (const zeroing of expected.zeroing) { expect(text).toContain(zeroing); } - expect(text).toMatch(expected.keepsPlanned); expect(text).toMatch(expected.zeroHours); }); + + // Deliberately a single-sentence assertion. Checking only that these words appear + // SOMEWHERE in the entry proves nothing: the ordinary type-by-type description has + // to name every type and both outcomes anyway, so the co-occurrence holds even with + // the warning deleted. The warning is a claim about the relationship between them, + // so it has to be tested as one sentence that carries all of it at once. + it(`${locale} warns, in one sentence, that ${expected.lookAlike} keeps the planned hours despite its name`, () => { + const warnings = sentences(flagsText(locale)) + .filter(sentence => sentence.includes(expected.lookAlike) && expected.nameTrap.test(sentence)); + + expect(warnings.length).toBeGreaterThan(0); + + const warning = warnings.join(' '); + expect(warning).toMatch(expected.keepsPlanned); + expect(warning).toMatch(expected.zeroHours); + for (const zeroing of expected.zeroing) { + expect(warning).toContain(zeroing); + } + }); } it('registers a locale for every expectation above', () => { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts index a96ff856..41f28e27 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts @@ -328,7 +328,7 @@ export const enUS: HelpProseMap = { 'dayCell.flags': { title: 'Day type', short: 'Marks what kind of day this is — vacation, sickness, course and so on. They look like checkboxes, but a day carries only one type at a time: ticking a new one unticks the previous one.', - detail: 'Ticking a type also sets what the day counts as. Day off and Vacation day off set it to zero hours. Vacation, Sick, Course, Leave of absence, Children 1st sick day, Children 2st sick day, Time off, Maternity leave, Holiday and Pregnancy-related absence all set it to the hours planned for that day. Watch the names: Time off reads like Day off and Vacation day off, but it does the opposite of both — it keeps the planned hours where they set the day to zero. Pick the type that matches what the day should count as. Unticking the type again removes that setting.', + detail: 'Ticking a type also sets what the day counts as. Day off and Vacation day off set it to zero hours. Vacation, Sick, Course, Leave of absence, Children 1st sick day, Children 2st sick day, Time off, Maternity leave, Holiday and Pregnancy-related absence all set it to the hours planned for that day. Watch the names: Time off reads like Day off and Vacation day off, but despite its name it keeps the planned hours, where those two set the day to zero hours. Pick the type that matches what the day should count as. Unticking the type again removes that setting.', keywords: ['day type', 'vacation', 'sickness', 'sick', 'course', 'maternity', 'leave', 'holiday', 'flag', 'absence', 'checkbox', 'day off', 'time off', 'mark day'], }, 'dayCell.commentOffice': { From f7242f68de9697348e471621b6c0ef0f1e7fa571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 14:04:58 +0200 Subject: [PATCH 14/38] feat(help): add diacritic-folding help search with tasks ranked first Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../help/services/help-search.service.spec.ts | 76 +++++++++++++++++ .../help/services/help-search.service.ts | 83 +++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts new file mode 100644 index 00000000..a276a4e9 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts @@ -0,0 +1,76 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpSearchService } from './help-search.service'; +import { HelpContentService } from './help-content.service'; + +describe('HelpSearchService', () => { + const make = (lang: string) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + HelpSearchService, + HelpContentService, + { provide: TranslateService, useValue: { currentLang: lang } }, + ], + }); + return TestBed.inject(HelpSearchService); + }; + + it('finds the vacation task from the Danish word', () => { + const ids = make('da').search('ferie', { isAdmin: false }).map(r => r.entry.id); + expect(ids).toContain('task.registerVacation'); + }); + + it('finds a Danish entry from an English word, through the fallback', () => { + const ids = make('da').search('vacation', { isAdmin: false }).map(r => r.entry.id); + expect(ids).toContain('task.registerVacation'); + }); + + it('folds diacritics so ae matches æ', () => { + const service = make('da'); + const withLigature = service.search('læge', { isAdmin: false }).map(r => r.entry.id); + const folded = service.search('laege', { isAdmin: false }).map(r => r.entry.id); + expect(folded).toEqual(withLigature); + }); + + it('folds ø and å', () => { + const service = make('da'); + expect(service.search('sygdom', { isAdmin: false }).length).toBeGreaterThan(0); + expect(service.search('arstid', { isAdmin: false })).toEqual( + service.search('årstid', { isAdmin: false }), + ); + }); + + it('ranks tasks above controls', () => { + const results = make('en-US').search('vacation', { isAdmin: false }); + const firstControl = results.findIndex(r => r.entry.kind === 'control'); + const lastTask = results.map(r => r.entry.kind).lastIndexOf('task'); + // Assert both groups are present, so a content edit that removes one cannot + // make this test pass without checking anything. + expect(firstControl).not.toBe(-1); + expect(lastTask).not.toBe(-1); + expect(lastTask).toBeLessThan(firstControl); + }); + + it('ranks a title match above a body-only match', () => { + const results = make('en-US').search('flex', { isAdmin: false }); + expect(results.length).toBeGreaterThan(1); + expect(results[0].prose.title.toLowerCase()).toContain('flex'); + }); + + it('returns the task list when nothing matches', () => { + const results = make('en-US').search('zzzznomatch', { isAdmin: false }); + expect(results.length).toBeGreaterThan(0); + expect(results.every(r => r.entry.kind === 'task')).toBe(true); + }); + + it('returns the task list for an empty query', () => { + const results = make('en-US').search(' ', { isAdmin: false }); + expect(results.every(r => r.entry.kind === 'task')).toBe(true); + }); + + it('never returns an admin-only entry to a non-admin', () => { + const ids = make('en-US').search('payroll', { isAdmin: false }).map(r => r.entry.id); + expect(ids).not.toContain('toolbar.payrollExport'); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.ts new file mode 100644 index 00000000..1bf42b4a --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@angular/core'; +import { HelpEntry, HelpEntryId, HelpProse } from '../help.model'; +import { HELP_FALLBACK } from '../i18n'; +import { HelpContentService } from './help-content.service'; + +export interface HelpSearchResult { + entry: HelpEntry; + prose: HelpProse; +} + +/** Match location, lower is better. */ +const RANK_TITLE = 0; +const RANK_KEYWORD = 1; +const RANK_BODY = 2; +const RANK_NONE = 99; + +const LIGATURES: Record = { æ: 'ae', ø: 'o', Æ: 'ae', Ø: 'o' }; + +export function fold(value: string): string { + return value + .replace(/[æøÆØ]/g, char => LIGATURES[char]) + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .trim(); +} + +@Injectable({ providedIn: 'root' }) +export class HelpSearchService { + constructor(private helpContent: HelpContentService) {} + + search(query: string, opts: { isAdmin: boolean }): HelpSearchResult[] { + const needle = fold(query); + const entries = this.helpContent.entries(opts); + + if (!needle) { + return this.tasksOnly(entries); + } + + const ranked = entries + .map(entry => ({ entry, prose: this.helpContent.prose(entry.id), rank: this.rank(entry.id, needle) })) + .filter(result => result.rank !== RANK_NONE); + + if (!ranked.length) { + return this.tasksOnly(entries); + } + + return ranked + .sort((a, b) => + (a.entry.kind === 'task' ? 0 : 1) - (b.entry.kind === 'task' ? 0 : 1) || + a.rank - b.rank) + .map(({ entry, prose }) => ({ entry, prose })); + } + + /** Best match location across the active locale and the English fallback. */ + private rank(id: HelpEntryId, needle: string): number { + const candidates = [this.helpContent.prose(id), HELP_FALLBACK[id]]; + let best = RANK_NONE; + + for (const prose of candidates) { + if (fold(prose.title).includes(needle)) { + return RANK_TITLE; + } + if (prose.keywords.some(keyword => fold(keyword).includes(needle))) { + // A keyword match already beats any body match, so skip the body scan. + best = Math.min(best, RANK_KEYWORD); + continue; + } + const body = [prose.short, prose.detail ?? '', ...(prose.steps ?? [])].join(' '); + if (fold(body).includes(needle)) { + best = Math.min(best, RANK_BODY); + } + } + + return best; + } + + private tasksOnly(entries: HelpEntry[]): HelpSearchResult[] { + return entries + .filter(entry => entry.kind === 'task') + .map(entry => ({ entry, prose: this.helpContent.prose(entry.id) })); + } +} From f5679a50ae257ef0c78dae2121d40431fd36196f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 14:12:41 +0200 Subject: [PATCH 15/38] fix(help-search): replace vacuous diacritic folding tests with real keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests 3 and 4 were vacuous: 'læge' and 'laege' don't exist in help content, so both queries returned the task fallback list. Replaced with real keywords that trigger fold() and verified via mutation testing (empty LIGATURES map breaks the tests, confirming non-vacuity). - Test 3 now searches 'fravær'/'fraVAER' (æ → ae folding) - Test 4 now searches 'løn'/'lon' (ø → o folding) + å assertion unchanged - Both assert non-empty results to block content edits that remove keywords Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../help/services/help-search.service.spec.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts index a276a4e9..662c163e 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts @@ -28,14 +28,24 @@ describe('HelpSearchService', () => { it('folds diacritics so ae matches æ', () => { const service = make('da'); - const withLigature = service.search('læge', { isAdmin: false }).map(r => r.entry.id); - const folded = service.search('laege', { isAdmin: false }).map(r => r.entry.id); - expect(folded).toEqual(withLigature); + const withLigature = service.search('fravær', { isAdmin: false }); + const folded = service.search('fraVAER', { isAdmin: false }).map(r => r.entry.id); + // Assert both searches return non-empty, so a content edit removing diacritics + // from keywords cannot make this test pass without verifying the match. + expect(withLigature.length).toBeGreaterThan(0); + expect(folded.length).toBeGreaterThan(0); + expect(folded).toEqual(withLigature.map(r => r.entry.id)); }); it('folds ø and å', () => { const service = make('da'); - expect(service.search('sygdom', { isAdmin: false }).length).toBeGreaterThan(0); + // ø: løn folds to lon + const withø = service.search('løn', { isAdmin: false }); + const withoø = service.search('lon', { isAdmin: false }).map(r => r.entry.id); + expect(withø.length).toBeGreaterThan(0); + expect(withoø.length).toBeGreaterThan(0); + expect(withoø).toEqual(withø.map(r => r.entry.id)); + // å: årstid folds to arstid expect(service.search('arstid', { isAdmin: false })).toEqual( service.search('årstid', { isAdmin: false }), ); From 81e722b0faa5c58ba88d2dfcf55c483aaba8e45b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 14:15:21 +0200 Subject: [PATCH 16/38] =?UTF-8?q?fix(help-search):=20replace=20vacuous=20?= =?UTF-8?q?=C3=A5=20assertion=20with=20real=20keyword=20'fratr=C3=A5dt'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original å assertion used 'årstid' which does not appear in help content, so both queries returned the tasksOnly fallback. Replaced with 'fratrådt' (a keyword on toolbar.showResigned) and verified via mutation testing (disabling the combining-mark strip in fold() breaks the å assertion). - 'fratrådt' folds to 'fratradt' (å → a via NFD + combining-mark strip) - Both accented and folded queries now assert non-empty results - Equality assertion guards against removing the keyword from content Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../help/services/help-search.service.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts index 662c163e..3d3f7ad8 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts @@ -45,10 +45,12 @@ describe('HelpSearchService', () => { expect(withø.length).toBeGreaterThan(0); expect(withoø.length).toBeGreaterThan(0); expect(withoø).toEqual(withø.map(r => r.entry.id)); - // å: årstid folds to arstid - expect(service.search('arstid', { isAdmin: false })).toEqual( - service.search('årstid', { isAdmin: false }), - ); + // å: fratrådt folds to fratradt + const withå = service.search('fratrådt', { isAdmin: false }); + const withoutå = service.search('fratradt', { isAdmin: false }).map(r => r.entry.id); + expect(withå.length).toBeGreaterThan(0); + expect(withoutå.length).toBeGreaterThan(0); + expect(withoutå).toEqual(withå.map(r => r.entry.id)); }); it('ranks tasks above controls', () => { From f5014266a80cfd7fc3e77c0dec2db7dc8c92d50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 14:16:34 +0200 Subject: [PATCH 17/38] docs: correct the spec's own vacuous diacritic-folding example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec illustrated the search test as "a Danish query folds diacritics (laege finds læge)". Neither term appears anywhere in the help content, so both queries miss and fall through to the same fallback list — the example asserts nothing, and the plan's tests inherited the flaw. Corrected to a term that actually appears in the content, and to require a non-empty result, since two queries that both match nothing compare equal whether or not folding works. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../specs/2026-09-04-planning-help-system-design.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md index 82359c72..e49c6790 100644 --- a/docs/superpowers/specs/2026-09-04-planning-help-system-design.md +++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md @@ -327,7 +327,10 @@ Two further rules, both already exercised above: a template exists in the registry; every registry id has prose in `enUS`. This is the test that prevents rot: it fails when someone typos an id, or deletes a control and leaves its help entry behind. -- **Search** — a Danish query folds diacritics (`laege` finds *læge*); an English query +- **Search** — a Danish query folds diacritics, asserted against a term that actually + appears in the content (`lon` finds the entries keyworded *løn*) and asserting a + non-empty result, since two queries that both match nothing compare equal regardless + of whether folding works; an English query finds a Danish-only entry through the English fallback; tasks sort above controls; a query matching nothing returns the task list rather than an empty result. - **Admin filtering** — a non-admin sees neither `adminOnly` entry in the panel, and From 32fe93a5c0a3933378d1cb5f18fe8731e49b7fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 14:21:22 +0200 Subject: [PATCH 18/38] feat(help): add tp-help-icon popover built on cdkConnectedOverlay Adds the in-page help info button: a mat-icon-button that opens a small connected-overlay popover with the entry's title and short text, plus a "More in help" affordance that emits openInPanel for a later task to bind. Uses a plain cdkConnectedOverlay - cdkConnectedOverlayUsePopover does not exist in the installed @angular/cdk 20.2.14. This still stacks above an open MatDialog because CDK appends later overlays after the dialog pane in .cdk-overlay-container. Chrome labels come from HelpContentService.ui(), never the ngx-translate pipe, so the plugin's 25 shared locale files gain no keys. An unregistered helpId renders nothing rather than throwing: the prose getter returns undefined when the registry misses, and the template body sits behind *ngIf. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../help-icon/help-icon.component.html | 33 ++++++++++ .../help-icon/help-icon.component.scss | 51 +++++++++++++++ .../help-icon/help-icon.component.spec.ts | 64 +++++++++++++++++++ .../help-icon/help-icon.component.ts | 55 ++++++++++++++++ .../time-planning-pn.module.ts | 6 +- 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.scss create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html new file mode 100644 index 00000000..017a0e67 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html @@ -0,0 +1,33 @@ + + + + + + + diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.scss b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.scss new file mode 100644 index 00000000..e40df01d --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.scss @@ -0,0 +1,51 @@ +.tp-help-icon { + width: 20px; + height: 20px; + line-height: 20px; + vertical-align: middle; + + .mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: var(--text-body, #7f868d); + } + + &:hover .mat-icon { + color: var(--primary, #289694); + } +} + +.tp-help-popover { + max-width: 320px; + padding: 12px 14px; + border: 1px solid var(--border, #e2e6e9); + border-radius: 8px; + background: var(--bg, #ffffff); + box-shadow: 0 2px 6px rgba(15, 19, 22, 0.1), 0 12px 32px rgba(15, 19, 22, 0.16); + + &__title { + margin: 0 0 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-header, #0f1316); + } + + &__body { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--text-body, #7f868d); + } + + &__more { + margin-top: 9px; + padding: 0; + border: 0; + background: none; + font-size: 12px; + font-weight: 500; + color: var(--primary, #289694); + cursor: pointer; + } +} diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.spec.ts new file mode 100644 index 00000000..c6ab5e85 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.spec.ts @@ -0,0 +1,64 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateService } from '@ngx-translate/core'; +import { HelpIconComponent } from './help-icon.component'; +import { enUS } from '../../i18n/enUS'; + +describe('HelpIconComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HelpIconComponent], + imports: [OverlayModule, NoopAnimationsModule, MatIconModule, MatButtonModule], + providers: [{ provide: TranslateService, useValue: { currentLang: 'en-US' } }], + }).compileComponents(); + + fixture = TestBed.createComponent(HelpIconComponent); + fixture.componentInstance.helpId = 'toolbar.dateRange'; + fixture.detectChanges(); + }); + + it('labels the button with the entry title', () => { + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button.getAttribute('aria-label')).toBe(enUS['toolbar.dateRange'].title); + }); + + it('starts closed and opens on click', () => { + expect(fixture.componentInstance.isOpen).toBe(false); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + expect(fixture.componentInstance.isOpen).toBe(true); + }); + + it('closes on Escape', () => { + fixture.componentInstance.isOpen = true; + fixture.componentInstance.onOverlayKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(fixture.componentInstance.isOpen).toBe(false); + }); + + it('ignores other keys', () => { + fixture.componentInstance.isOpen = true; + fixture.componentInstance.onOverlayKeydown(new KeyboardEvent('keydown', { key: 'a' })); + expect(fixture.componentInstance.isOpen).toBe(true); + }); + + it('emits the id when More is used, and closes', () => { + const seen: string[] = []; + fixture.componentInstance.openInPanel.subscribe(id => seen.push(id)); + fixture.componentInstance.isOpen = true; + fixture.componentInstance.onMore(); + expect(seen).toEqual(['toolbar.dateRange']); + expect(fixture.componentInstance.isOpen).toBe(false); + }); + + it('renders nothing for an unknown id rather than throwing', () => { + const other = TestBed.createComponent(HelpIconComponent); + other.componentInstance.helpId = 'nope' as never; + expect(() => other.detectChanges()).not.toThrow(); + expect(other.nativeElement.querySelector('button')).toBeNull(); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.ts new file mode 100644 index 00000000..64318a21 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.ts @@ -0,0 +1,55 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { HelpEntryId, HelpProse, HelpUiStrings } from '../../help.model'; +import { HelpContentService } from '../../services/help-content.service'; + +@Component({ + selector: 'tp-help-icon', + templateUrl: './help-icon.component.html', + styleUrls: ['./help-icon.component.scss'], + standalone: false, +}) +export class HelpIconComponent { + @Input() helpId!: HelpEntryId; + @Output() openInPanel = new EventEmitter(); + + isOpen = false; + + readonly positions: ConnectedPosition[] = [ + { originX: 'center', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 6 }, + { originX: 'center', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -6 }, + { originX: 'center', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 6 }, + { originX: 'center', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -6 }, + ]; + + constructor(private helpContent: HelpContentService) {} + + /** Undefined for an id the registry does not know, so the template renders nothing. */ + get prose(): HelpProse | undefined { + return this.helpContent.entry(this.helpId) ? this.helpContent.prose(this.helpId) : undefined; + } + + /** Help chrome labels. Never the shared ngx-translate catalogue. */ + get ui(): HelpUiStrings { + return this.helpContent.ui(); + } + + toggle(): void { + this.isOpen = !this.isOpen; + } + + close(): void { + this.isOpen = false; + } + + onOverlayKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.close(); + } + } + + onMore(): void { + this.openInPanel.emit(this.helpId); + this.close(); + } +} diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts b/eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts index 4f56e8b6..f9b372a8 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/time-planning-pn.module.ts @@ -49,6 +49,8 @@ import {MatCheckbox} from '@angular/material/checkbox'; import {MatRadioButton, MatRadioGroup} from '@angular/material/radio'; import {MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle} from '@angular/material/dialog'; import {MtxSelect} from '@ng-matero/extensions/select'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {HelpIconComponent} from './help/components/help-icon/help-icon.component'; @NgModule({ imports: [ @@ -94,7 +96,8 @@ import {MtxSelect} from '@ng-matero/extensions/select'; MatStartDate, MatEndDate, MatPrefix, - MatError + MatError, + OverlayModule ], declarations: [ TimePlanningPnLayoutComponent, @@ -106,6 +109,7 @@ import {MtxSelect} from '@ng-matero/extensions/select'; TimePlanningsTableComponent, TimePlanningsContainerComponent, PayrollExportDialogComponent, + HelpIconComponent, ], providers: [ TimePlanningPnSettingsService, From 7de16b4a776223b802c7c29e2ff819de8185f4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 4 Sep 2026 14:28:25 +0200 Subject: [PATCH 19/38] fix(help): dismiss the help popover without a backdrop, and label it a note Two post-review corrections to tp-help-icon. Drop the backdrop. A transparent full-page backdrop swallows the first click, and on this page every day cell is a click target that opens the day editor, so dismissing a popover cost a planner an extra click. Switch to hasBackdrop=false with (overlayOutsideClick), which dismisses on the same click that reaches the control underneath. Escape handling is unchanged. Correct the ARIA role. The popover carried role="dialog" while implementing no focus management, advertising modal semantics it does not honour. Use role="note", which describes what it is: a small non-modal informational aside. No focus management added on purpose - a non-modal popover that steals focus would be worse here. aria-expanded on the trigger is unchanged. Adds three tests: outside-click dismissal with no backdrop present, inside-click keeping it open so "More in help" stays clickable, and the role/aria-label pair. Both new assertions were confirmed to fail against the previous template. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B5LtfrpnXpu2p9FrWFsSnB --- .../help-icon/help-icon.component.html | 7 ++-- .../help-icon/help-icon.component.spec.ts | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html index 017a0e67..45e230b7 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html @@ -16,13 +16,12 @@ [cdkConnectedOverlayOrigin]="helpOrigin" [cdkConnectedOverlayOpen]="isOpen" [cdkConnectedOverlayPositions]="positions" - [cdkConnectedOverlayHasBackdrop]="true" - cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" + [cdkConnectedOverlayHasBackdrop]="false" [cdkConnectedOverlayPush]="true" - (backdropClick)="close()" + (overlayOutsideClick)="close()" (overlayKeydown)="onOverlayKeydown($event)" (detach)="close()"> -