+```
+
+- [ ] **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`, `@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**
+
+```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, 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';
+
+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();
+
+ @Output() replayTourRequested = new EventEmitter();
+
+ constructor(
+ private helpContent: HelpContentService,
+ private helpSearch: HelpSearchService,
+ private helpPanel: HelpPanelService,
+ ) {}
+
+ 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();
+ }
+
+ /**
+ * 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();
+ this.replayTourRequested.emit();
+ }
+
+ 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
+
+
+
+ {{ (state!.index + 1) }} / {{ state!.total }}
+
+
{{ tourProse.title }}
+
{{ tourProse.short }}
+
+
+
+
+
+
+```
+
+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('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");
+ });
+});
+```
+
+- [ ] **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
+
+
+```
+
+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`:
+
+```ts
+get helpUi(): HelpUiStrings {
+ return this.helpContent.ui();
+}
+
+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 {
+ 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
+
+
+```
+
+`tp-help-tour` takes `[tour]` only. It has no `isAdmin` input — admin filtering happens inside `HelpTourService.start(tour, { isAdmin })`, which the container and the dialog each call. Each mounted instance renders only its own tour, so the page and dialog instances do not collide.
+
+- [ ] **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.
+
+If the dialog tears the tour down explicitly when it closes, it must call
+`HelpTourService.abort()`, **not** `stop()`. `stop()` marks the tour seen — it is the
+user-initiated end, used by Skip, Escape and completion. `abort()` ends the tour without
+marking it seen, and is for the page changing underneath: someone who opens a row,
+glances and closes it has not seen the tour and must still be offered it.
+
+- [ ] **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
new file mode 100644
index 00000000..4e60ad55
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-04-planning-help-system-design.md
@@ -0,0 +1,394 @@
+# 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 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.
+
+## 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:19-24`). Inside the
+page, exactly two things are admin-only:
+
+| Control | Gate |
+|---|---|
+| 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
+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 =
+ | '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 = { ... };
+```
+
+`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.
+
+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
+`cdkConnectedOverlay` anchored to the button via `cdkOverlayOrigin`, with a
+close-on-scroll strategy and fallback positions. Dismissed on Escape
+(`overlayKeydown`), an outside click (`overlayOutsideClick`), and scroll.
+
+Deliberately **no 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 a planner dismissing a popover would have to click twice to reach the cell
+underneath. `overlayOutsideClick` gives the same dismissal without the tax.
+
+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
+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
+`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 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
+- The "Total planned hours cannot exceed 24" validation
+- An empty grid when filters match no workers
+- **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
+
+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 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 |
+
+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`,
+`Maternity`, `VacationDayOff`, `Holiday`, `PregnancyLeave`. `Blank` and `Care` are
+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)
+
+`showResigned`, `navBackward`, `navForward`, `workerFilter`, `tagFilter`, `dateRange`,
+`downloadExcel`, `payrollExport` (adminOnly), `reload`.
+
+### `grid` — controls (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` — controls (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` — controls (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 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.
+
+## Rollout: admin-only first
+
+The whole help system — the `?` button, the panel, both tours, every ⓘ and every inline
+hint — is currently visible only to admins, i.e. only to Microting. This is a staged
+rollout, not a change of audience: the content is still written for the team lead, and the
+copy rules below still bind, because customers see it when the gate lifts.
+
+The gate lives in three choke points rather than at the ~20 template call sites, so no
+usage site can leak: `HelpVisibilityService` (root, live subscription to
+`selectCurrentUserIsAdmin` — never `take(1)`, which would latch a stale value),
+read by `HelpEntryChromeBase.prose`, by `HelpTourService.start()`, and by
+`HelpPanelComponent`. A tour refused by the gate is deliberately **not** marked seen, so a
+user still gets their one automatic offer once the gate is lifted.
+
+Lifting the gate should be a small change in those three places plus the `?` button. Two
+things to revisit at that point:
+
+- `grid.nameColumn`'s copy currently under-describes its only remaining audience — it never
+ says that clicking the column opens the worker's settings. That sentence must not be
+ added while the gate is up, and if it is ever added it must not be phrased as an
+ administrator capability.
+- `toolbar.payrollExport` keeps its own `adminOnly` flag, which is a permanent property of
+ that entry rather than part of this rollout.
+
+## 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
+ English text; a present entry does not.
+- **Registry integrity** — every entry with a `tourStep` also declares a `tour` and an
+ `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, 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
+ 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.
diff --git a/eform-client/playwright.config.ts b/eform-client/playwright.config.ts
index 64b24ee1..04a999fb 100644
--- a/eform-client/playwright.config.ts
+++ b/eform-client/playwright.config.ts
@@ -13,6 +13,15 @@ export default defineConfig({
retries: 0,
use: {
baseURL: 'http://localhost:4200',
+ // Seeds localStorage so the planning page's onboarding tours count as already
+ // seen. Playwright gives every test a fresh context with empty storage, so
+ // without this both tours auto-start: the page tour drops a card over the top
+ // grid rows and the dialog tour drops one over the shift-1 fields, and
+ // Playwright's actionability check then fails on the intercepting overlay for
+ // every spec that clicks a day cell or #saveButton. The seed matches
+ // TOUR_STORAGE_KEY in help/services/help-tour.service.ts; a jest test in the
+ // plugin (help/playwright-tour-seed.spec.ts) fails if the two drift apart.
+ storageState: 'playwright/helpers/tour-seen.storage.json',
viewport: { width: 1920, height: 1080 },
video: 'on',
screenshot: 'only-on-failure',
diff --git a/eform-client/playwright/helpers/tour-seen.storage.json b/eform-client/playwright/helpers/tour-seen.storage.json
new file mode 100644
index 00000000..f98d4dd9
--- /dev/null
+++ b/eform-client/playwright/helpers/tour-seen.storage.json
@@ -0,0 +1,14 @@
+{
+ "cookies": [],
+ "origins": [
+ {
+ "origin": "http://localhost:4200",
+ "localStorage": [
+ {
+ "name": "tp.planning.tour.v1",
+ "value": "[\"page\",\"dialog\"]"
+ }
+ ]
+ }
+ ]
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html
index e690e94e..1f6b06d7 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html
@@ -5,10 +5,12 @@
({{ data.planningPrDayModels.id }})
+
+
+
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.scss b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.scss
index 2aab2ef5..3141f1fb 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.scss
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.scss
@@ -20,6 +20,20 @@
gap: 16px; /* Adjust spacing between elements */
}
+/* A form field paired with its help icon. These fields were direct children of
+ the .d-flex.flex-column column, where a flex item stretches to the column
+ width; putting them in a row would otherwise shrink them to mat-form-field's
+ intrinsic width and leave the un-paired fields beside them looking ragged. */
+.field-with-help {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ mat-form-field {
+ flex: 1 1 auto;
+ }
+}
+
.workday-dialog-container {
display: flex;
gap: 20px;
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts
index 985febef..d8e7483d 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts
@@ -12,6 +12,8 @@ import { Store } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import { DomSanitizer } from '@angular/platform-browser';
import { TemplateFilesService } from 'src/app/common/services';
+import { HelpPanelService } from '../../../../help/services/help-panel.service';
+import { HelpTourService, TOUR_STORAGE_KEY } from '../../../../help/services/help-tour.service';
describe('WorkdayEntityDialogComponent', () => {
let component: WorkdayEntityDialogComponent;
@@ -158,6 +160,37 @@ describe('WorkdayEntityDialogComponent', () => {
expect(component).toBeTruthy();
});
+ describe('Help wiring', () => {
+ it('tells the panel it was opened from the dialog', async () => {
+ // The panel is mounted once, on the page behind this dialog. Without the
+ // surface its "Take the tour" button replays the PAGE tour, whose anchors
+ // are all behind this dialog's backdrop.
+ const panel = TestBed.inject(HelpPanelService);
+ const surfaces: string[] = [];
+ panel.surface$.subscribe(surface => surfaces.push(surface));
+
+ component.openHelp('dayCell.save');
+
+ expect(surfaces[surfaces.length - 1]).toBe('dialog');
+ });
+
+ it('offers the dialog tour with the real isAdmin, not a hardcoded false', () => {
+ jest.useFakeTimers();
+ localStorage.removeItem(TOUR_STORAGE_KEY);
+ const tour = TestBed.inject(HelpTourService);
+ const start = jest.spyOn(tour, 'start').mockImplementation(() => undefined);
+
+ component.isAdmin = true;
+ (component as any).startDialogTourOnce();
+ jest.runAllTimers();
+
+ expect(start).toHaveBeenCalledWith('dialog', { isAdmin: true });
+
+ start.mockRestore();
+ jest.useRealTimers();
+ });
+ });
+
describe('Time Conversion Utilities', () => {
describe('convertMinutesToTime', () => {
it('should return null for zero minutes', () => {
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts
index 2bfa9879..a623c275 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts
@@ -10,13 +10,16 @@ import {MtxGridColumn} from '@ng-matero/extensions/grid';
import {TimePlanningPnPlanningsService, TimePlanningPnGpsCoordinatesService, TimePlanningPnPictureSnapshotsService} from '../../../../services';
import {VersionHistoryModalComponent} from '../version-history-modal/version-history-modal.component';
import {Store} from '@ngrx/store';
-import {selectCurrentUserIsFirstUser} from 'src/app/state';
+import {selectCurrentUserIsFirstUser, selectCurrentUserIsAdmin} from 'src/app/state';
import validator from 'validator';
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
import {TemplateFilesService} from 'src/app/common/services';
import {SharedTagModel} from 'src/app/common/models';
import {Subscription} from 'rxjs';
import { MatDialogRef } from '@angular/material/dialog';
+import {HelpEntryId} from '../../../../help/help.model';
+import {HelpPanelService} from '../../../../help/services/help-panel.service';
+import {HelpTourService} from '../../../../help/services/help-tour.service';
import {
AbstractControl,
@@ -52,11 +55,17 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy {
protected datePipe = inject(DatePipe);
private translateService = inject(TranslateService);
private dialogRef = inject(MatDialogRef);
+ private helpPanel = inject(HelpPanelService);
+ private helpTour = inject(HelpTourService);
private originalDialogWidth: string = '600px';
private originalDialogHeight: string = 'auto';
public selectCurrentUserIsFirstUser$ = this.store.select(selectCurrentUserIsFirstUser);
+ /** Drives which help entries the dialog tour may include. */
+ isAdmin = false;
+ private isAdmin$: Subscription;
+
TimePlanningMessagesEnum = TimePlanningMessagesEnum;
enumKeys: string[] = [];
tableHeaders: MtxGridColumn[] = [];
@@ -120,6 +129,10 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy {
snapshotDataMap: Map = new Map();
private readonly GOOGLE_MAPS_EMBED_URL = 'https://www.google.com/maps?q={lat},{lng}&output=embed';
+ /** True while the tour on screen is this dialog's, so closing can end it. */
+ private dialogTourRunning = false;
+ private helpTourState$: Subscription;
+
ngOnInit(): void {
@@ -431,6 +444,33 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy {
this.updateDisabledStates();
this.loadGpsAndSnapshotData();
+
+ this.isAdmin$ = this.store.select(selectCurrentUserIsAdmin)
+ .subscribe(isAdmin => this.isAdmin = !!isAdmin);
+ this.helpTourState$ = this.helpTour.state$.subscribe(state => {
+ this.dialogTourRunning = state?.entry.tour === 'dialog';
+ });
+ this.startDialogTourOnce();
+ }
+
+ /**
+ * The panel is mounted once, on the page behind this dialog. Telling it which
+ * surface asked for it is what makes its "Take the tour" button replay the
+ * DIALOG tour rather than the page one, whose anchors all sit behind this
+ * dialog's backdrop.
+ */
+ openHelp(target?: HelpEntryId): void {
+ this.helpPanel.open(target, 'dialog');
+ }
+
+ private startDialogTourOnce(): void {
+ if (this.helpTour.hasSeen('dialog')) {
+ return;
+ }
+ // The anchors only exist once this pass has rendered the form and the shift grid.
+ // isAdmin is passed through rather than hardcoded: no dialog entry is adminOnly
+ // today, but a later one would otherwise be dropped from the tour in silence.
+ setTimeout(() => this.helpTour.start('dialog', { isAdmin: this.isAdmin }));
}
// inside class:
@@ -1997,5 +2037,13 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.imageSub$?.unsubscribe();
this.revokeSnapshotUrl();
+ this.isAdmin$?.unsubscribe();
+ this.helpTourState$?.unsubscribe();
+ if (this.dialogTourRunning) {
+ // abort(), not stop(): closing a row is the page changing underneath the
+ // tour, not the planner saying they are done with it, so it must still be
+ // offered the next time a day is opened.
+ this.helpTour.abort();
+ }
}
}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html
index 508ceba8..b4484d6a 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html
@@ -10,6 +10,7 @@
@@ -122,3 +143,6 @@
(highlightedRowRendered)="onHighlightedRowRendered()"
>
+
+
+
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts
index a48c58bf..c923bee3 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts
@@ -8,6 +8,8 @@ import { of } from 'rxjs';
import { format } from 'date-fns';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
+import { HelpTourService } from '../../../help/services/help-tour.service';
+import { HelpVisibilityService } from '../../../help/services/help-visibility.service';
describe('TimePlanningsContainerComponent', () => {
let component: TimePlanningsContainerComponent;
@@ -235,4 +237,101 @@ describe('TimePlanningsContainerComponent', () => {
);
});
});
+
+ describe('Page help tour', () => {
+ let tour: HelpTourService;
+ let start: jest.SpyInstance;
+
+ beforeEach(() => {
+ localStorage.clear();
+ tour = TestBed.inject(HelpTourService);
+ start = jest.spyOn(tour, 'start').mockImplementation(() => undefined);
+ jest.useFakeTimers();
+ component.dateFrom = new Date(2024, 0, 15);
+ component.dateTo = new Date(2024, 0, 21);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ start.mockRestore();
+ localStorage.clear();
+ });
+
+ it('does not offer the tour while the grid has no rows', () => {
+ // HelpTourService marks a tour seen as soon as it runs out of steps, and
+ // that flag is persisted, so an empty first load would drop the three grid
+ // steps and suppress them for good.
+ mockPlanningsService.getPlannings.mockReturnValue(of({ success: true, model: [] }) as any);
+
+ component.getPlannings();
+ jest.runAllTimers();
+
+ expect(start).not.toHaveBeenCalled();
+ });
+
+ it('offers the tour once rows have arrived', () => {
+ mockPlanningsService.getPlannings.mockReturnValue(
+ of({ success: true, model: [{ siteId: 1, siteName: 'A' }] }) as any);
+
+ component.getPlannings();
+ jest.runAllTimers();
+
+ // A literal, not component.isAdmin: reading the expected value off the
+ // component under test asserts nothing about what was passed.
+ expect(start).toHaveBeenCalledWith('page', { isAdmin: false });
+ });
+
+ it('does not burn the once-per-page offer on a tour the help gate refuses', () => {
+ // pageTourOffered is a latch for the life of the container. Setting it
+ // around a start the gate refuses would mean this planner never gets the
+ // tour on this page visit, even once help becomes visible to them.
+ const visibility = TestBed.inject(HelpVisibilityService);
+ const isVisible = jest.spyOn(visibility, 'isVisible', 'get').mockReturnValue(false);
+ mockPlanningsService.getPlannings.mockReturnValue(
+ of({ success: true, model: [{ siteId: 1, siteName: 'A' }] }) as any);
+
+ component.getPlannings();
+ jest.runAllTimers();
+ expect(start).not.toHaveBeenCalled();
+
+ isVisible.mockReturnValue(true);
+ component.getPlannings();
+ jest.runAllTimers();
+ expect(start).toHaveBeenCalledWith('page', { isAdmin: false });
+
+ isVisible.mockRestore();
+ });
+
+ it('does not re-offer the tour on every reload', () => {
+ mockPlanningsService.getPlannings.mockReturnValue(
+ of({ success: true, model: [{ siteId: 1, siteName: 'A' }] }) as any);
+
+ component.getPlannings();
+ jest.runAllTimers();
+ component.getPlannings();
+ jest.runAllTimers();
+
+ expect(start).toHaveBeenCalledTimes(1);
+ });
+
+ it('replays whichever tour the panel names, including the dialog one', () => {
+ // start('dialog') is otherwise called from one place, gated on hasSeen, so
+ // this is the only route back to the dialog tour once it has been skipped.
+ component.replayTour('dialog');
+ jest.runAllTimers();
+
+ expect(start).toHaveBeenCalledWith('dialog', { isAdmin: false });
+ });
+
+ it('does not offer a tour the planner has already seen', () => {
+ tour.markSeen('page');
+ mockPlanningsService.getPlannings.mockReturnValue(
+ of({ success: true, model: [{ siteId: 1, siteName: 'A' }] }) as any);
+
+ component.getPlannings();
+ jest.runAllTimers();
+
+ expect(start).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts
index afe680f3..21f9ebd5 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts
@@ -16,6 +16,11 @@ import {selectCurrentUserLocale, selectCurrentUserIsAdmin} from 'src/app/state';
import {MatDialog} from '@angular/material/dialog';
import {DownloadExcelDialogComponent, PayrollExportDialogComponent} from 'src/app/plugins/modules/time-planning-pn/components';
import {MatDatepickerInputEvent} from '@angular/material/datepicker';
+import {HelpEntryId, HelpTourName, HelpUiStrings} from '../../../help/help.model';
+import {HelpContentService} from '../../../help/services/help-content.service';
+import {HelpPanelService} from '../../../help/services/help-panel.service';
+import {HelpTourService} from '../../../help/services/help-tour.service';
+import {HelpVisibilityService} from '../../../help/services/help-visibility.service';
@AutoUnsubscribe()
@Component({
@@ -29,6 +34,11 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy {
private planningsService = inject(TimePlanningPnPlanningsService);
private settingsService = inject(TimePlanningPnSettingsService);
private dialog = inject(MatDialog);
+ private helpContent = inject(HelpContentService);
+ private helpPanel = inject(HelpPanelService);
+ private helpTour = inject(HelpTourService);
+ /** Protected, not private: the ? button binds isVisible$ straight from the template. */
+ protected helpVisibility = inject(HelpVisibilityService);
timePlanningsRequest: TimePlanningsRequestModel;
availableSites: SiteDto[] = [];
@@ -50,6 +60,14 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy {
public selectCurrentUserLocale$ = this.store.select(selectCurrentUserLocale);
locale: string;
+ /**
+ * The page tour is offered once per session at most. hasSeen() alone is not
+ * enough: it only flips when the tour ends, and getPlannings() reruns on every
+ * filter change, so an unfinished tour would otherwise restart from step 1 each
+ * time the grid reloads.
+ */
+ private pageTourOffered = false;
+
ngOnInit(): void {
// Load available tags
this.settingsService
@@ -129,9 +147,52 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy {
if (data && data.success) {
this.timePlannings = data.model;
}
+ this.startPageTourOnce();
});
}
+ /** Help chrome labels. Never the shared ngx-translate catalogue. */
+ get helpUi(): HelpUiStrings {
+ return this.helpContent.ui();
+ }
+
+ openHelp(target?: HelpEntryId): void {
+ this.helpPanel.open(target);
+ }
+
+ /**
+ * Replays whichever tour the panel says applies to the surface it was opened
+ * from. Opened from the toolbar that is the page tour; opened from inside the
+ * day-cell dialog it is the dialog tour, whose anchors are the only ones in
+ * front of the dialog backdrop. This is also the only way the dialog tour can
+ * be seen a second time: the dialog itself offers it once, gated on hasSeen.
+ */
+ replayTour(tour: HelpTourName): void {
+ // The panel has already closed itself; let that settle before querying anchors.
+ setTimeout(() => this.helpTour.start(tour, { isAdmin: this.isAdmin }));
+ }
+
+ private startPageTourOnce(): void {
+ // Steps 4-6 point at grid rows. HelpTourService records a tour as seen the
+ // moment it runs out of steps, and that flag lives in localStorage, so
+ // offering the tour on an empty grid would drop those three steps and then
+ // permanently suppress them. Wait for rows.
+ // The gate is checked here rather than after the fact, because
+ // pageTourOffered is a once-per-page-visit latch: burning it on a start the
+ // gate refuses would mean the tour never comes up again for this container
+ // instance, even if help becomes visible a moment later.
+ if (this.pageTourOffered
+ || this.timePlannings.length === 0
+ || !this.helpVisibility.isVisible
+ || this.helpTour.hasSeen('page')) {
+ return;
+ }
+ this.pageTourOffered = true;
+ // Let the current change-detection pass render the grid, or start() finds no
+ // anchors and drops every step it was meant to point at.
+ setTimeout(() => this.helpTour.start('page', { isAdmin: this.isAdmin }));
+ }
+
ngOnDestroy(): void {
}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html
index 4dccff3f..9b222658 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html
@@ -1,3 +1,21 @@
+
+
+
+
+
+
+
+
-
flightpregnant_womansick
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts
index 637b26e5..0f5e9ced 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts
@@ -14,6 +14,9 @@ import * as R from 'ramda';
import {TimePlanningMessagesEnum} from '../../../enums';
import {Store} from '@ngrx/store';
import {selectAuthIsAdmin, selectCurrentUserIsFirstUser} from 'src/app/state';
+import {applyGridHelpAnchors} from '../../../help/grid-help-anchors';
+import {HelpEntryId} from '../../../help/help.model';
+import {HelpPanelService} from '../../../help/services/help-panel.service';
@Component({
selector: 'app-time-plannings-table',
@@ -32,6 +35,7 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView
protected datePipe = inject(DatePipe);
private cdr = inject(ChangeDetectorRef);
private el = inject(ElementRef);
+ private helpPanel = inject(HelpPanelService);
@Input() timePlannings: TimePlanningModel[] = [];
@Input() dateFrom!: Date;
@@ -81,7 +85,20 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView
}
}
+ /**
+ * "More in help" from the Name-column icon. The table opens the panel itself
+ * rather than emitting to the container: the panel is a page-level singleton
+ * reached through its service, and routing this one click up through an output
+ * would add a hop that carries no extra information.
+ */
+ openHelp(target: HelpEntryId): void {
+ this.helpPanel.open(target);
+ }
+
ngAfterViewChecked(): void {
+ // mtx-grid owns its header row, so the Name column's sort header is the one
+ // help anchor that cannot be written in the template.
+ applyGridHelpAnchors(this.el.nativeElement);
if (this.pendingHighlight && !this.highlightApplied && !this.waitingForFreshData && this.timePlannings?.length) {
const rowIndex = this.timePlannings.findIndex(tp => tp.siteId === this.pendingHighlight.siteId);
if (rowIndex >= 0) {
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-chrome.base.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-chrome.base.ts
new file mode 100644
index 00000000..0f5f8f96
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-chrome.base.ts
@@ -0,0 +1,47 @@
+import { Directive, inject, Input } from '@angular/core';
+import { HelpEntryId, HelpProse, HelpUiStrings } from '../help.model';
+import { HelpContentService } from '../services/help-content.service';
+import { HelpVisibilityService } from '../services/help-visibility.service';
+
+/**
+ * Chrome labels for the help components. They come from HelpUiStrings via
+ * HelpContentService and never from the plugin's 25 shared ngx-translate locale
+ * files, which this feature must not add keys to — so every help component needs
+ * the same one-line accessor, and it lives here rather than four times over.
+ *
+ * Abstract and unselected: it is never declared in a module, only extended.
+ */
+@Directive()
+export abstract class HelpChromeBase {
+ protected readonly helpContent = inject(HelpContentService);
+ protected readonly helpVisibility = inject(HelpVisibilityService);
+
+ get ui(): HelpUiStrings {
+ return this.helpContent.ui();
+ }
+
+ /** Whether help exists for this user at all. See HelpVisibilityService. */
+ get isVisible(): boolean {
+ return this.helpVisibility.isVisible;
+ }
+}
+
+/** A help component that renders the prose of one registry entry. */
+@Directive()
+export abstract class HelpEntryChromeBase extends HelpChromeBase {
+ @Input() helpId!: HelpEntryId;
+
+ /**
+ * Undefined for an id the registry does not know, so the template renders
+ * nothing — and undefined for a non-admin, for the same reason. Both the icon
+ * and the hint template are wrapped in `*ngIf="prose as ..."`, so this one
+ * getter is what hides every ⓘ and every inline hint at once, rather than an
+ * *ngIf repeated at each of the eighteen call sites.
+ */
+ get prose(): HelpProse | undefined {
+ if (!this.isVisible) {
+ return undefined;
+ }
+ return this.helpContent.entry(this.helpId) ? this.helpContent.prose(this.helpId) : undefined;
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.html
new file mode 100644
index 00000000..a04cda47
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.html
@@ -0,0 +1,4 @@
+
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.scss b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.scss
new file mode 100644
index 00000000..c2432ffb
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.scss
@@ -0,0 +1,29 @@
+.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);
+ }
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.spec.ts
new file mode 100644
index 00000000..6ca17a95
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.spec.ts
@@ -0,0 +1,81 @@
+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 { of } from 'rxjs';
+import { enUS } from '../../i18n/enUS';
+import { HelpVisibilityService } from '../../services/help-visibility.service';
+
+/**
+ * The one dependency the help chrome gained when help became admin-only. A stub
+ * rather than a mock store: HelpVisibilityService is the only thing the chrome
+ * asks, so these specs do not need ngrx at all. It defaults to visible, so every
+ * assertion below still covers the admin case it was written for.
+ */
+const helpVisibility = { isVisible: true, isVisible$: of(true) };
+const provideHelpVisibility = { provide: HelpVisibilityService, useValue: helpVisibility };
+
+
+describe('HelpHintComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ // The stub is shared by every case here; the gate tests flip it.
+ helpVisibility.isVisible = true;
+ await TestBed.configureTestingModule({
+ declarations: [HelpHintComponent],
+ imports: [MatIconModule],
+ providers: [
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ }).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();
+ });
+
+ it('renders the correct icon based on tone', () => {
+ fixture.componentInstance.helpId = 'dayCell.futureDisabled';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.mat-icon').textContent).toContain('info');
+
+ fixture.componentInstance.tone = 'warn';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.mat-icon').textContent).toContain('warning');
+ });
+
+ it('renders nothing when help is not visible, and the hint again when it is', () => {
+ fixture.componentInstance.helpId = 'dayCell.futureDisabled';
+ helpVisibility.isVisible = false;
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.help-text')).toBeNull();
+
+ // The admin half: otherwise the assertion above is indistinguishable from
+ // the component rendering nothing under any circumstances.
+ helpVisibility.isVisible = true;
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.help-text')).not.toBeNull();
+ expect(fixture.nativeElement.textContent).toContain(enUS['dayCell.futureDisabled'].short);
+ });
+});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.ts
new file mode 100644
index 00000000..10afb835
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-hint/help-hint.component.ts
@@ -0,0 +1,12 @@
+import { Component, Input } from '@angular/core';
+import { HelpEntryChromeBase } from '../help-chrome.base';
+
+@Component({
+ selector: 'tp-help-hint',
+ templateUrl: './help-hint.component.html',
+ styleUrls: ['./help-hint.component.scss'],
+ standalone: false,
+})
+export class HelpHintComponent extends HelpEntryChromeBase {
+ @Input() tone: 'info' | 'warn' = 'info';
+}
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..9e34d0f4
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.html
@@ -0,0 +1,33 @@
+
+
+ info_outline
+
+
+
+
+
{{ helpProse.title }}
+
{{ helpProse.short }}
+
+ {{ ui.moreInHelp }}
+
+
+
+
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..d4ba6473
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.scss
@@ -0,0 +1,68 @@
+.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(--tp-td-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;
+ }
+}
+
+/**
+ * The grid's Name column is pinned left, so this variant puts the icon on its own
+ * line flush to that edge, immediately above the table, where it reads as
+ * introducing the column rather than the grid as a whole.
+ */
+:host(.tp-help-icon--name-column) {
+ display: block;
+ margin-bottom: 4px;
+}
+
+/* Help is admin-only, and the host still renders for everyone else — as an empty
+ element with a margin. Comment nodes do not disqualify :empty, so the *ngIf
+ anchor Angular leaves behind still matches. */
+:host(.tp-help-icon--name-column):empty {
+ display: none;
+}
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..1e320825
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-icon/help-icon.component.spec.ts
@@ -0,0 +1,185 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CloseScrollStrategy, 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 { of } from 'rxjs';
+import { enUS } from '../../i18n/enUS';
+import { HelpVisibilityService } from '../../services/help-visibility.service';
+
+/**
+ * The one dependency the help chrome gained when help became admin-only. A stub
+ * rather than a mock store: HelpVisibilityService is the only thing the chrome
+ * asks, so these specs do not need ngrx at all. It defaults to visible, so every
+ * assertion below still covers the admin case it was written for.
+ */
+const helpVisibility = { isVisible: true, isVisible$: of(true) };
+const provideHelpVisibility = { provide: HelpVisibilityService, useValue: helpVisibility };
+
+
+describe('HelpIconComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ // The stub is shared by every case here; the gate tests flip it.
+ helpVisibility.isVisible = true;
+ await TestBed.configureTestingModule({
+ declarations: [HelpIconComponent],
+ imports: [OverlayModule, NoopAnimationsModule, MatIconModule, MatButtonModule],
+ providers: [
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ }).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);
+ });
+
+ // These three drive real events through the template bindings rather than
+ // calling the handlers. Calling onOverlayKeydown() or onMore() directly proves
+ // only that the methods work: delete (overlayKeydown) or (click)="onMore()"
+ // from the template and such tests stay green while the control goes inert.
+ const press = (key: string) =>
+ // CDK's OverlayKeyboardDispatcher listens on document.body and routes to the
+ // topmost open overlay, which is what (overlayKeydown) is fed from.
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+
+ it('closes on Escape', () => {
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+ expect(document.querySelector('.tp-help-popover')).not.toBeNull();
+
+ press('Escape');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isOpen).toBe(false);
+ expect(document.querySelector('.tp-help-popover')).toBeNull();
+ });
+
+ it('ignores other keys', () => {
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+
+ press('a');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isOpen).toBe(true);
+ expect(document.querySelector('.tp-help-popover')).not.toBeNull();
+ });
+
+ 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.detectChanges();
+
+ const more = document.querySelector('.tp-help-popover__more') as HTMLButtonElement;
+ expect(more).not.toBeNull();
+ more.click();
+ fixture.detectChanges();
+
+ expect(seen).toEqual(['toolbar.dateRange']);
+ expect(fixture.componentInstance.isOpen).toBe(false);
+ });
+
+ it('closes when a click lands outside the popover, without a backdrop to swallow it', () => {
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+
+ // No backdrop means the same click that dismisses the popover reaches the
+ // control underneath - on this page every day cell is a click target.
+ expect(document.querySelector('.cdk-overlay-backdrop')).toBeNull();
+
+ const outside = document.createElement('button');
+ document.body.appendChild(outside);
+ outside.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ fixture.detectChanges();
+ outside.remove();
+
+ expect(fixture.componentInstance.isOpen).toBe(false);
+ });
+
+ it('stays open when the click lands inside the popover', () => {
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+
+ const body = document.querySelector('.tp-help-popover__body') as HTMLElement;
+ body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isOpen).toBe(true);
+ });
+
+ it('describes the popover as a note, not a modal dialog it does not implement', () => {
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+
+ const popover = document.querySelector('.tp-help-popover');
+ expect(popover).not.toBeNull();
+ expect(popover.getAttribute('role')).toBe('note');
+ expect(popover.getAttribute('aria-label')).toBe(enUS['toolbar.dateRange'].title);
+ });
+
+ it('binds a close-on-scroll strategy, not the injected reposition default', () => {
+ // CdkConnectedOverlay's injected default is createRepositionScrollStrategy,
+ // which would leave the popover glued to a trigger scrolled out of view.
+ expect(fixture.componentInstance.scrollStrategy).toBeInstanceOf(CloseScrollStrategy);
+ });
+
+ it('dismisses on scroll', () => {
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+ expect(document.querySelector('.tp-help-popover')).not.toBeNull();
+
+ // ScrollDispatcher listens for 'scroll' on the document and pushes through
+ // its scrolled() stream, which the close strategy is subscribed to.
+ document.dispatchEvent(new Event('scroll'));
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isOpen).toBe(false);
+ expect(document.querySelector('.tp-help-popover')).toBeNull();
+ });
+
+ 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();
+ });
+
+ it('renders no icon and no popover at all when help is not visible', () => {
+ // The gate lives in HelpEntryChromeBase.prose, which is what the whole
+ // template hangs off, so a non-admin gets no trigger and — even if isOpen is
+ // forced — no popover either.
+ helpVisibility.isVisible = false;
+ fixture.componentInstance.isOpen = true;
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('button')).toBeNull();
+ expect(document.querySelector('.tp-help-popover')).toBeNull();
+
+ // And an admin still gets all of it. Without this half, the assertions above
+ // would pass just as well if the component had been deleted.
+ helpVisibility.isVisible = true;
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('button')).not.toBeNull();
+ expect(document.querySelector('.tp-help-popover')).not.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..e53e7427
--- /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, Output } from '@angular/core';
+import { ConnectedPosition, Overlay, ScrollStrategy } from '@angular/cdk/overlay';
+import { HelpEntryId } from '../../help.model';
+import { HelpEntryChromeBase } from '../help-chrome.base';
+
+@Component({
+ selector: 'tp-help-icon',
+ templateUrl: './help-icon.component.html',
+ styleUrls: ['./help-icon.component.scss'],
+ standalone: false,
+})
+export class HelpIconComponent extends HelpEntryChromeBase {
+ @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 },
+ ];
+
+ /**
+ * Dismiss on scroll rather than following the trigger. This popover means
+ * "this explains the control next to me"; in a horizontally scrolling grid,
+ * CDK's default reposition strategy would leave it floating over unrelated
+ * columns, or trailing a trigger the planner has already scrolled past.
+ */
+ readonly scrollStrategy: ScrollStrategy;
+
+ constructor(overlay: Overlay) {
+ super();
+ this.scrollStrategy = overlay.scrollStrategies.close();
+ }
+
+ 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/help/components/help-panel/help-panel.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.html
new file mode 100644
index 00000000..2c62288b
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.html
@@ -0,0 +1,107 @@
+
+
+
+
+
{{ entryProse.detail }}
+
+
{{ step }}
+
+ 0">
+
{{ ui.relatedControls }}
+
+
+
+ {{ prose(relatedId).title }}
+
+
+
+
+
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.scss b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.scss
new file mode 100644
index 00000000..dcee0e65
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.scss
@@ -0,0 +1,229 @@
+.tp-help-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ /* The open panel is parked inside .cdk-overlay-container. That container is
+ position: fixed with z-index 1000, so it is a stacking context and this
+ value is scoped to it, not raised over the whole page. Inside it, CDK gives
+ .cdk-overlay-backdrop, .cdk-global-overlay-wrapper and .cdk-overlay-pane
+ z-index 1000 each, so DOM order alone does not put the panel on top: below
+ 1000 it paints under the day-cell dialog's backdrop, is dimmed by it, and
+ loses hit-testing to it — a click on the panel would reach the backdrop and
+ close the dialog. */
+ z-index: 1001;
+ /* .cdk-overlay-container is pointer-events: none so overlays do not swallow
+ page clicks. Panes opt back in individually; this panel has to do the same. */
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ width: 340px;
+ max-width: 100vw;
+ height: 100vh;
+ border-left: 1px solid var(--border, #e2e6e9);
+ background: var(--tp-td-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;
+ color: var(--text-header, #0f1316);
+ }
+ }
+
+ &__spacer {
+ flex: 1;
+ }
+
+ &__search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 11px 15px 0;
+ padding: 8px 13px;
+ border: 1px solid var(--border, #e2e6e9);
+ border-radius: 19px;
+
+ .mat-icon {
+ flex: none;
+ width: 17px;
+ height: 17px;
+ font-size: 17px;
+ color: var(--text-body, #7f868d);
+ }
+
+ input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ background: none;
+ font-size: 13px;
+ color: var(--text-header, #0f1316);
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ button {
+ display: flex;
+ flex: none;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+ }
+ }
+
+ &__replay {
+ padding: 9px 15px 0;
+
+ button {
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 12px;
+ color: var(--primary, #289694);
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ &__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 {
+ display: flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ padding: 1px 3px;
+ border: 1px solid var(--border, #e2e6e9);
+ border-radius: 3px;
+ color: var(--text-body, #7f868d);
+
+ .mat-icon {
+ width: 13px;
+ height: 13px;
+ font-size: 13px;
+ line-height: 13px;
+ }
+
+ &--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);
+ }
+
+ &__detail {
+ margin-top: 6px;
+ }
+
+ &__steps {
+ margin: 8px 0 0;
+ padding-left: 17px;
+
+ li {
+ margin-bottom: 3px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text-header, #0f1316);
+ }
+ }
+
+ &__related-title {
+ margin-top: 10px;
+ font-weight: 600;
+ color: var(--text-header, #0f1316);
+ }
+
+ &__related {
+ margin: 3px 0 0;
+ padding: 0;
+ list-style: none;
+
+ li {
+ margin-bottom: 1px;
+ }
+ }
+
+ &__related-link {
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--primary, #289694);
+ text-align: left;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ text-decoration: underline;
+ }
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.spec.ts
new file mode 100644
index 00000000..7e4b1405
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.spec.ts
@@ -0,0 +1,530 @@
+import { SimpleChange } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { TranslateService } from '@ngx-translate/core';
+import { of } from 'rxjs';
+import { HelpEntryId, HelpTourName } from '../../help.model';
+import { enUS, enUSUi } from '../../i18n/enUS';
+import { HelpPanelService } from '../../services/help-panel.service';
+import { HelpVisibilityService } from '../../services/help-visibility.service';
+import { HelpPanelComponent } from './help-panel.component';
+
+/**
+ * The one dependency the help chrome gained when help became admin-only. A stub
+ * rather than a mock store: HelpVisibilityService is the only thing the chrome
+ * asks, so these specs do not need ngrx at all. It defaults to visible, so every
+ * assertion below still covers the admin case it was written for.
+ */
+const helpVisibility = { isVisible: true, isVisible$: of(true) };
+const provideHelpVisibility = { provide: HelpVisibilityService, useValue: helpVisibility };
+
+
+describe('HelpPanelComponent', () => {
+ let fixture: ComponentFixture;
+ let component: HelpPanelComponent;
+ let panel: HelpPanelService;
+
+ const panelEl = () => fixture.nativeElement.querySelector('.tp-help-panel') as HTMLElement | null;
+ const text = () => (fixture.nativeElement as HTMLElement).textContent ?? '';
+ const sectionHeadings = () =>
+ Array.from(fixture.nativeElement.querySelectorAll('.tp-help-panel__section'))
+ .map(el => ((el as HTMLElement).textContent ?? '').trim());
+ const browsedIds = () => component.sections.flatMap(group => group.entries.map(entry => entry.id));
+ const resultIds = () => component.results.map(result => result.entry.id);
+ const countText = () =>
+ ((fixture.nativeElement.querySelector('.tp-help-panel__count') as HTMLElement | null)?.textContent ?? '')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ beforeEach(async () => {
+ // The stub is shared by every case here; the gate tests flip it.
+ helpVisibility.isVisible = true;
+ await TestBed.configureTestingModule({
+ declarations: [HelpPanelComponent],
+ imports: [FormsModule, MatIconModule, MatButtonModule],
+ providers: [
+ HelpPanelService,
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(HelpPanelComponent);
+ component = fixture.componentInstance;
+ panel = TestBed.inject(HelpPanelService);
+ fixture.detectChanges();
+ });
+
+ it('renders nothing while closed', () => {
+ expect(component.isOpen).toBe(false);
+ expect(panelEl()).toBeNull();
+ });
+
+ it('renders its chrome from the help ui strings, never a translate key', () => {
+ panel.open();
+ fixture.detectChanges();
+
+ const header = fixture.nativeElement.querySelector('.tp-help-panel__top h5') as HTMLElement;
+ const input = fixture.nativeElement.querySelector('.tp-help-panel__search input') as HTMLInputElement;
+ const closeButton = fixture.nativeElement.querySelector('.tp-help-panel__top button') as HTMLElement;
+ const replayButton = fixture.nativeElement.querySelector('.tp-help-panel__replay button') as HTMLElement;
+
+ expect(header.textContent?.trim()).toBe(enUSUi.help);
+ expect(input.placeholder).toBe(enUSUi.searchHelp);
+ expect(closeButton.getAttribute('aria-label')).toBe(enUSUi.close);
+ expect(replayButton.textContent?.trim()).toBe(enUSUi.replayTour);
+ expect(text()).not.toContain('sectionToolbar');
+ });
+
+ it('browses grouped sections in order when open with no query', () => {
+ panel.open();
+ fixture.detectChanges();
+
+ expect(panelEl()).not.toBeNull();
+ expect(component.isSearching).toBe(false);
+ expect(component.sections.map(group => group.section))
+ .toEqual(['task', 'toolbar', 'grid', 'dayCell', 'flex']);
+ expect(sectionHeadings()).toEqual([
+ enUSUi.sectionTask,
+ enUSUi.sectionToolbar,
+ enUSUi.sectionGrid,
+ enUSUi.sectionDayCell,
+ enUSUi.sectionFlex,
+ ]);
+ expect(text()).toContain(enUS['toolbar.dateRange'].title);
+ expect(text()).toContain(enUS['toolbar.dateRange'].short);
+ });
+
+ it('groups every entry under its own section', () => {
+ component.isAdmin = true;
+ panel.open();
+ fixture.detectChanges();
+
+ for (const group of component.sections) {
+ expect(group.entries.every(entry => entry.section === group.section)).toBe(true);
+ }
+ expect(browsedIds()).toContain('toolbar.payrollExport');
+ });
+
+ it('switches to results when a query is typed and back when it is cleared', () => {
+ panel.open();
+ fixture.detectChanges();
+
+ component.onQueryChange('vacation');
+ fixture.detectChanges();
+
+ expect(component.isSearching).toBe(true);
+ expect(component.results.length).toBeGreaterThan(0);
+ expect(resultIds()).toContain('task.registerVacation');
+ expect(sectionHeadings()).toEqual([]);
+ expect(component.isFallback).toBe(false);
+ expect(countText()).toBe(`${component.results.length} ${enUSUi.resultCount}`);
+ expect(countText()).not.toContain(enUSUi.noResults);
+
+ component.clearQuery();
+ fixture.detectChanges();
+
+ expect(component.isSearching).toBe(false);
+ expect(component.results).toEqual([]);
+ expect(sectionHeadings().length).toBe(5);
+ });
+
+ it('drives the query from the search input through ngModel', () => {
+ panel.open();
+ fixture.detectChanges();
+
+ const input = fixture.nativeElement.querySelector('.tp-help-panel__search input') as HTMLInputElement;
+ input.value = 'vacation';
+ input.dispatchEvent(new Event('input'));
+ fixture.detectChanges();
+
+ expect(component.query).toBe('vacation');
+ expect(component.isSearching).toBe(true);
+ expect(resultIds()).toContain('task.registerVacation');
+ });
+
+ it('treats a whitespace-only query as browsing', () => {
+ panel.open();
+ component.onQueryChange(' ');
+ fixture.detectChanges();
+
+ expect(component.isSearching).toBe(false);
+ expect(sectionHeadings().length).toBe(5);
+ });
+
+ it('hides admin-only entries when isAdmin is false, in browse and in results', () => {
+ component.isAdmin = false;
+ panel.open();
+ fixture.detectChanges();
+
+ expect(browsedIds()).not.toContain('toolbar.payrollExport');
+ expect(text()).not.toContain(enUS['toolbar.payrollExport'].title);
+
+ component.onQueryChange('payroll');
+ fixture.detectChanges();
+
+ expect(resultIds().length).toBeGreaterThan(0);
+ expect(resultIds()).not.toContain('toolbar.payrollExport');
+ });
+
+ it('shows admin-only entries in results when isAdmin is true', () => {
+ component.isAdmin = true;
+ panel.open();
+ component.onQueryChange('payroll');
+ fixture.detectChanges();
+
+ expect(resultIds()).toContain('toolbar.payrollExport');
+ expect(text()).toContain(enUS['toolbar.payrollExport'].title);
+ });
+
+ it('marks and expands the deep-link target', () => {
+ panel.open('flex.sumFlex');
+ fixture.detectChanges();
+
+ expect(component.targetId).toBe('flex.sumFlex');
+ expect(component.expanded).toBe('flex.sumFlex');
+
+ const marked = fixture.nativeElement.querySelector('.tp-help-entry--target') as HTMLElement;
+ expect(marked).not.toBeNull();
+ expect(marked.textContent).toContain(enUS['flex.sumFlex'].title);
+ expect(marked.textContent).toContain(enUS['flex.sumFlex'].detail as string);
+ });
+
+ it('expands and collapses an entry on click', () => {
+ panel.open();
+ fixture.detectChanges();
+
+ expect(text()).not.toContain(enUS['task.registerVacation'].steps?.[0] as string);
+
+ component.toggleEntry('task.registerVacation');
+ fixture.detectChanges();
+
+ const steps = Array.from(fixture.nativeElement.querySelectorAll('.tp-help-entry__steps li'))
+ .map(el => ((el as HTMLElement).textContent ?? '').trim());
+ expect(steps).toEqual(enUS['task.registerVacation'].steps);
+
+ component.toggleEntry('task.registerVacation');
+ fixture.detectChanges();
+
+ expect(component.expanded).toBeNull();
+ expect(fixture.nativeElement.querySelector('.tp-help-entry__steps')).toBeNull();
+ });
+
+ it('closes through the service and forgets the query', () => {
+ panel.open();
+ component.onQueryChange('vacation');
+ fixture.detectChanges();
+
+ component.close();
+ fixture.detectChanges();
+
+ expect(panelEl()).toBeNull();
+ expect(component.query).toBe('');
+ expect(component.results).toEqual([]);
+
+ panel.open();
+ fixture.detectChanges();
+
+ expect(component.isSearching).toBe(false);
+ expect(sectionHeadings().length).toBe(5);
+ });
+
+ const clickReplay = () =>
+ (fixture.nativeElement.querySelector('.tp-help-panel__replay button') as HTMLElement).click();
+
+ it('closes and asks the host to replay the page tour', () => {
+ const replays: HelpTourName[] = [];
+ component.replayTourRequested.subscribe(tour => replays.push(tour));
+ panel.open();
+ fixture.detectChanges();
+
+ clickReplay();
+ fixture.detectChanges();
+
+ expect(replays).toEqual(['page']);
+ expect(panelEl()).toBeNull();
+ });
+
+ it('asks for the dialog tour when it was opened from the day-cell dialog', () => {
+ // One panel serves both surfaces. Replaying the page tour from inside the
+ // dialog would anchor every step behind the dialog backdrop and leave a card
+ // nobody can reach until the dialog is closed.
+ const replays: HelpTourName[] = [];
+ component.replayTourRequested.subscribe(tour => replays.push(tour));
+ panel.open('dayCell.save', 'dialog');
+ fixture.detectChanges();
+
+ clickReplay();
+ fixture.detectChanges();
+
+ expect(replays).toEqual(['dialog']);
+ });
+
+ it('goes back to the page tour once the panel has closed', () => {
+ panel.open('dayCell.save', 'dialog');
+ fixture.detectChanges();
+ expect(component.surface).toBe('dialog');
+
+ panel.close();
+ fixture.detectChanges();
+ panel.open();
+ fixture.detectChanges();
+
+ expect(component.surface).toBe('page');
+ });
+
+ it('says "1 result", not "1 results"', () => {
+ panel.open();
+ // 'avatar' appears in exactly one entry's prose.
+ component.onQueryChange('avatar');
+ fixture.detectChanges();
+
+ expect(component.results.length).toBe(1);
+ expect(enUSUi.resultCountOne).not.toBe(enUSUi.resultCount);
+ expect(countText()).toBe(`1 ${enUSUi.resultCountOne}`);
+ });
+
+ it('ends an expanded task with links to the controls it touches', () => {
+ // `related` is registry data the panel is the only consumer of; unrendered it
+ // is dead weight the integrity spec alone keeps honest.
+ panel.open();
+ fixture.detectChanges();
+ component.toggleEntry('task.registerVacation');
+ fixture.detectChanges();
+
+ const links = Array.from(
+ fixture.nativeElement.querySelectorAll('.tp-help-entry__related-link'),
+ ).map(el => ((el as HTMLElement).textContent ?? '').trim());
+
+ const related = component.related('task.registerVacation');
+ expect(related.length).toBeGreaterThan(0);
+ expect(links).toEqual(related.map(id => enUS[id].title));
+ expect(text()).toContain(enUSUi.relatedControls);
+ });
+
+ it('moves the panel to a related control when its link is used', () => {
+ panel.open();
+ fixture.detectChanges();
+ component.toggleEntry('task.registerVacation');
+ fixture.detectChanges();
+
+ const first = component.related('task.registerVacation')[0];
+ (fixture.nativeElement.querySelector('.tp-help-entry__related-link') as HTMLElement).click();
+ fixture.detectChanges();
+
+ expect(component.expanded).toBe(first);
+ expect(component.targetId).toBe(first);
+ expect(fixture.nativeElement.querySelector('.tp-help-entry--target')).not.toBeNull();
+ });
+
+ it('keeps the dialog surface when a related link is followed', () => {
+ // Navigating inside the panel is not moving to another surface. open() defaults
+ // the surface to 'page', so forwarding it here is what stops a related link from
+ // silently turning the dialog tour back into the page tour.
+ const replays: HelpTourName[] = [];
+ component.replayTourRequested.subscribe(tour => replays.push(tour));
+ // A deep link already expands its target, so there is nothing to toggle.
+ panel.open('task.registerVacation', 'dialog');
+ fixture.detectChanges();
+ expect(component.expanded).toBe('task.registerVacation');
+
+ (fixture.nativeElement.querySelector('.tp-help-entry__related-link') as HTMLElement).click();
+ fixture.detectChanges();
+
+ expect(component.surface).toBe('dialog');
+
+ clickReplay();
+ fixture.detectChanges();
+
+ expect(replays).toEqual(['dialog']);
+ });
+
+ it('drops a related link to an entry the reader is not allowed to see', () => {
+ // A link into an entry the panel does not list would deep-link to a row that
+ // is not there. task.exportForPayroll points at the admin-only payroll export.
+ component.isAdmin = false;
+ expect(component.related('task.exportForPayroll')).not.toContain('toolbar.payrollExport');
+
+ component.isAdmin = true;
+ expect(component.related('task.exportForPayroll')).toContain('toolbar.payrollExport');
+ });
+
+ it('names the query and offers the tasks when nothing matches', () => {
+ panel.open();
+ component.onQueryChange('zzzqqq');
+ fixture.detectChanges();
+
+ expect(component.isSearching).toBe(true);
+ expect(component.isFallback).toBe(true);
+ expect(component.results.length).toBeGreaterThan(0);
+ expect(component.results.every(result => result.entry.kind === 'task')).toBe(true);
+ expect(countText()).toContain('zzzqqq');
+ expect(countText()).toContain(enUSUi.noResults);
+ expect(countText()).not.toContain(enUSUi.resultCount);
+ expect(text()).toContain(enUS['task.registerVacation'].title);
+ });
+
+ it('names each result kind for screen readers', () => {
+ component.isAdmin = true;
+ panel.open();
+ component.onQueryChange('payroll');
+ fixture.detectChanges();
+
+ const labels = Array.from(fixture.nativeElement.querySelectorAll('.tp-help-entry__kind'))
+ .map(el => (el as HTMLElement).getAttribute('aria-label'));
+ expect(labels.length).toBe(component.results.length);
+ expect(labels).toEqual(component.results.map(result =>
+ result.entry.kind === 'task' ? enUSUi.kindTask : enUSUi.kindControl));
+ expect(labels).toContain(enUSUi.kindControl);
+ });
+
+ it('closes on Escape, and only while open', () => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ fixture.detectChanges();
+ expect(component.isOpen).toBe(false);
+
+ panel.open();
+ fixture.detectChanges();
+ expect(panelEl()).not.toBeNull();
+
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ fixture.detectChanges();
+
+ expect(component.isOpen).toBe(false);
+ expect(panelEl()).toBeNull();
+ });
+
+ it('swallows Escape before the CDK dispatcher can close the day-cell dialog', () => {
+ // CDK's OverlayKeyboardDispatcher listens for keydown on document.body in the
+ // bubble phase and routes it to the topmost overlay - which, when help is
+ // opened from inside a day cell, is the MatDialog holding unsaved edits. A
+ // plain document listener runs after that. Dispatch the event the way a real
+ // key press reaches the page (from the focused element, bubbling through body)
+ // and assert the body listener never sees it.
+ panel.open();
+ fixture.detectChanges();
+
+ const dispatcherStandIn = jest.fn();
+ document.body.addEventListener('keydown', dispatcherStandIn);
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
+ document.body.dispatchEvent(event);
+ document.body.removeEventListener('keydown', dispatcherStandIn);
+ fixture.detectChanges();
+
+ expect(dispatcherStandIn).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(true);
+ expect(component.isOpen).toBe(false);
+ expect(panelEl()).toBeNull();
+ });
+
+ it('lets Escape through to the page once the panel has closed', () => {
+ // The shield must not outlive the panel, or Escape would stop closing the
+ // day-cell dialog at all.
+ const dispatcherStandIn = jest.fn();
+ document.body.addEventListener('keydown', dispatcherStandIn);
+ document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ document.body.removeEventListener('keydown', dispatcherStandIn);
+
+ expect(dispatcherStandIn).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves other keys alone while open', () => {
+ panel.open();
+ fixture.detectChanges();
+
+ const dispatcherStandIn = jest.fn();
+ document.body.addEventListener('keydown', dispatcherStandIn);
+ document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }));
+ document.body.removeEventListener('keydown', dispatcherStandIn);
+ fixture.detectChanges();
+
+ expect(dispatcherStandIn).toHaveBeenCalledTimes(1);
+ expect(component.isOpen).toBe(true);
+ });
+
+ it('rebuilds what isAdmin filters when it arrives after opening', () => {
+ panel.open();
+ component.onQueryChange('payroll');
+ fixture.detectChanges();
+
+ expect(browsedIds()).not.toContain('toolbar.payrollExport');
+ expect(resultIds()).not.toContain('toolbar.payrollExport');
+
+ component.isAdmin = true;
+ component.ngOnChanges({ isAdmin: new SimpleChange(false, true, false) });
+ fixture.detectChanges();
+
+ expect(browsedIds()).toContain('toolbar.payrollExport');
+ expect(resultIds()).toContain('toolbar.payrollExport');
+ });
+
+ it('does not rebuild on the first isAdmin change, or while closed', () => {
+ // Both guards, on their false side. ngOnChanges fires once at creation with
+ // firstChange true, before the panel has ever opened; rebuilding then would
+ // hand *ngFor a fresh array on every open and drop focus and scroll position.
+ const rebuild = jest.spyOn(component as any, 'buildSections');
+
+ component.isAdmin = true;
+ component.ngOnChanges({ isAdmin: new SimpleChange(undefined, true, true) });
+ expect(rebuild).not.toHaveBeenCalled();
+
+ // Not the first change any more, but the panel is closed.
+ component.ngOnChanges({ isAdmin: new SimpleChange(true, false, false) });
+ expect(rebuild).not.toHaveBeenCalled();
+
+ // A change that is neither of those does rebuild, so the test above is not
+ // passing because buildSections is unreachable.
+ panel.open();
+ fixture.detectChanges();
+ rebuild.mockClear();
+ component.ngOnChanges({ isAdmin: new SimpleChange(false, true, false) });
+ expect(rebuild).toHaveBeenCalled();
+
+ rebuild.mockRestore();
+ });
+
+ it('stops listening to the panel service once destroyed', () => {
+ fixture.destroy();
+
+ panel.open('flex.sumFlex' as HelpEntryId);
+
+ expect(component.isOpen).toBe(false);
+ expect(component.targetId).toBeNull();
+ });
+
+ it('does not open for a non-admin, however it is asked to', () => {
+ // isAdmin only filters the entries listed inside an already-open panel. The
+ // panel itself has to refuse to exist, or a stray open() — a deep link, a
+ // leftover keyboard shortcut — puts the whole catalogue on screen.
+ helpVisibility.isVisible = false;
+
+ panel.open();
+ fixture.detectChanges();
+ expect(component.isOpen).toBe(false);
+ expect(panelEl()).toBeNull();
+
+ panel.open('flex.sumFlex' as HelpEntryId);
+ fixture.detectChanges();
+ expect(panelEl()).toBeNull();
+
+ // And the markup is gated too, so isOpen being set by any other route than
+ // the service — a refactor, a test, a subclass — still renders nothing.
+ component.isOpen = true;
+ fixture.detectChanges();
+ expect(panelEl()).toBeNull();
+ component.isOpen = false;
+
+ // An admin opens it exactly as before.
+ helpVisibility.isVisible = true;
+ panel.close();
+ fixture.detectChanges();
+ panel.open();
+ fixture.detectChanges();
+ expect(component.isOpen).toBe(true);
+ expect(panelEl()).not.toBeNull();
+ });
+});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.ts
new file mode 100644
index 00000000..bd0a0385
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.ts
@@ -0,0 +1,340 @@
+import {
+ AfterViewChecked, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy,
+ OnInit, Output, SimpleChanges,
+} from '@angular/core';
+import { OverlayContainer } from '@angular/cdk/overlay';
+import { Subscription } from 'rxjs';
+import {
+ HelpEntry, HelpEntryId, HelpProse, HelpSection, HelpTourName,
+} from '../../help.model';
+import { HelpPanelService } from '../../services/help-panel.service';
+import { HelpSearchResult, HelpSearchService } from '../../services/help-search.service';
+import { HelpChromeBase } from '../help-chrome.base';
+
+export interface PanelSection {
+ section: HelpSection;
+ entries: HelpEntry[];
+}
+
+/** Tasks first — that is what a planner came looking for — then the page top to bottom. */
+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 extends HelpChromeBase
+ implements OnInit, OnChanges, AfterViewChecked, OnDestroy {
+ @Input() isAdmin = false;
+
+ /**
+ * Asks the host to replay a tour, naming which one. The panel deliberately does
+ * not depend on HelpTourService — keeping the two independent means neither has
+ * to know about the other, and the host already owns where the tour is anchored.
+ * The name matters because one panel serves two surfaces: opened from the
+ * day-cell dialog it must replay the dialog tour, whose anchors are the only
+ * ones reachable while the dialog's backdrop is up.
+ */
+ @Output() replayTourRequested = new EventEmitter();
+
+ isOpen = false;
+ targetId: HelpEntryId | null = null;
+
+ /** The tour belonging to the surface the panel was opened from. */
+ surface: HelpTourName = 'page';
+ query = '';
+ results: HelpSearchResult[] = [];
+ sections: PanelSection[] = [];
+ expanded: HelpEntryId | null = null;
+
+ private readonly subscriptions = new Subscription();
+
+ /**
+ * A deep link expands its target, but the panel still opens scrolled to the
+ * top, so a target low in the list — any of the flex entries — lands off
+ * screen. Set when a target arrives, cleared once it has been scrolled to.
+ */
+ private pendingTargetScroll = false;
+
+ /** Where the panel host sits when it is not parked in the overlay container. */
+ private originalParent: Node | null = null;
+ private originalNextSibling: Node | null = null;
+ private pendingFocus = false;
+
+ /** Whether the capture-phase Escape listener is currently on document. */
+ private escapeCaptureBound = false;
+
+ /** What had focus when the panel opened, so closing can hand it back. */
+ private focusOnOpen: HTMLElement | null = null;
+
+ constructor(
+ private helpSearch: HelpSearchService,
+ private helpPanel: HelpPanelService,
+ private host: ElementRef,
+ private overlayContainer: OverlayContainer,
+ ) {
+ super();
+ }
+
+ get isSearching(): boolean {
+ return this.query.trim().length > 0;
+ }
+
+ /**
+ * True when the query matched nothing and the search handed back the task list
+ * instead. The panel says so rather than passing twelve tasks off as hits.
+ */
+ get isFallback(): boolean {
+ return this.results.length > 0 && this.results.every(result => result.fallback === true);
+ }
+
+ kindLabel(entry: HelpEntry): string {
+ return entry.kind === 'task' ? this.ui.kindTask : this.ui.kindControl;
+ }
+
+ ngOnInit(): void {
+ this.subscriptions.add(this.helpPanel.isOpen$.subscribe(requested => {
+ // The panel is help chrome like everything else, and help is admin-only
+ // for now. Refusing the open here — not just hiding the markup — keeps a
+ // stray open() from reparenting the host into the overlay container,
+ // stealing focus and binding the capture-phase Escape handler for a user
+ // who has no help at all.
+ const isOpen = requested && this.isVisible;
+ // open() re-emits even when the panel is already open — a second
+ // "More in help" deep-links into the open panel. Only a genuine
+ // closed -> open transition may move focus, or that second click would
+ // yank the planner out of whatever they were reading.
+ const wasOpen = this.isOpen;
+ this.isOpen = isOpen;
+ if (isOpen) {
+ this.moveIntoOverlayContainer();
+ this.bindEscapeCapture();
+ if (!wasOpen) {
+ // Only on a real closed -> open transition. Rebuilding the sections
+ // hands *ngFor a fresh array and re-creates every entry node, which
+ // drops both focus and scroll position; nothing but isAdmin changes
+ // what is listed, and ngOnChanges already rebuilds for that.
+ this.buildSections();
+ const active = document.activeElement;
+ this.focusOnOpen = active instanceof HTMLElement ? active : null;
+ this.pendingFocus = true;
+ }
+ } else {
+ this.unbindEscapeCapture();
+ this.onQueryChange('');
+ this.restoreFromOverlayContainer();
+ this.pendingFocus = false;
+ this.returnFocus();
+ }
+ }));
+ this.subscriptions.add(this.helpPanel.surface$.subscribe(surface => {
+ this.surface = surface;
+ }));
+ this.subscriptions.add(this.helpPanel.target$.subscribe(target => {
+ this.targetId = target;
+ this.expanded = target;
+ this.pendingTargetScroll = target !== null;
+ }));
+ }
+
+ /** isAdmin can arrive after the panel is already open; rebuild what it filters. */
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['isAdmin'] && !changes['isAdmin'].firstChange && this.isOpen) {
+ this.buildSections();
+ this.onQueryChange(this.query);
+ }
+ }
+
+ ngAfterViewChecked(): void {
+ if (this.pendingFocus) {
+ // The panel can be opened from inside the modal day-cell dialog, whose
+ // focus trap wraps Tab within itself. Handing focus to the search input is
+ // what makes the panel reachable at all from there.
+ const search = this.host.nativeElement
+ .querySelector('.tp-help-panel__search input');
+ if (search) {
+ this.pendingFocus = false;
+ search.focus();
+ }
+ }
+ if (!this.pendingTargetScroll) {
+ return;
+ }
+ const target = this.host.nativeElement.querySelector('.tp-help-entry--target');
+ if (target) {
+ this.pendingTargetScroll = false;
+ // Optional call: jsdom and other non-layout hosts do not implement it.
+ target.scrollIntoView?.({ block: 'nearest' });
+ }
+ }
+
+ /**
+ * Escape closes the panel and must not reach anything underneath it. The panel
+ * is a plain element rather than an OverlayRef, so CDK's OverlayKeyboardDispatcher
+ * does not shield it the way it shields the popover and the tour card: that
+ * dispatcher listens on document.body in the bubble phase, which runs BEFORE a
+ * document-level bubble listener, and it would hand Escape to the day-cell
+ * dialog's MatDialogRef (disableClose is false by default). Opening help from
+ * inside a day dialog and pressing Escape would then close the editor and
+ * discard unsaved edits. Listening on document in the CAPTURE phase puts this
+ * handler ahead of the dispatcher, and stopping propagation there means the
+ * event never descends to body at all.
+ */
+ private readonly onEscapeCapture = (event: KeyboardEvent): void => {
+ if (!this.isOpen || event.key !== 'Escape') {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ this.close();
+ };
+
+ ngOnDestroy(): void {
+ this.unbindEscapeCapture();
+ this.restoreFromOverlayContainer();
+ this.subscriptions.unsubscribe();
+ }
+
+ private bindEscapeCapture(): void {
+ if (this.escapeCaptureBound) {
+ return;
+ }
+ this.escapeCaptureBound = true;
+ document.addEventListener('keydown', this.onEscapeCapture, true);
+ }
+
+ private unbindEscapeCapture(): void {
+ if (!this.escapeCaptureBound) {
+ return;
+ }
+ this.escapeCaptureBound = false;
+ document.removeEventListener('keydown', this.onEscapeCapture, true);
+ }
+
+ /**
+ * CDK's Dialog marks every body sibling of .cdk-overlay-container
+ * aria-hidden="true" while a modal is open, and the day-cell dialog's help
+ * icons link into this panel. Left where it is declared, the panel would open
+ * hidden from screen readers and behind the dialog. Inside the container it is
+ * not aria-hidden; the SCSS raises it above the container's own backdrop and
+ * panes, which all sit at z-index 1000.
+ */
+ private moveIntoOverlayContainer(): void {
+ const host = this.host.nativeElement;
+ const container = this.overlayContainer.getContainerElement();
+ if (host.parentNode === container) {
+ return;
+ }
+ if (host.parentNode) {
+ this.originalParent = host.parentNode;
+ this.originalNextSibling = host.nextSibling;
+ }
+ container.appendChild(host);
+ }
+
+ /** Put the host back where it was before Angular tears the view down around it. */
+ private restoreFromOverlayContainer(): void {
+ const host = this.host.nativeElement;
+ if (!this.originalParent || host.parentNode === this.originalParent) {
+ return;
+ }
+ // insertBefore(node, null) appends, so a panel that was last stays last.
+ // Appending unconditionally would walk the host down past its siblings on
+ // every open/close cycle.
+ const before = this.originalNextSibling?.parentNode === this.originalParent
+ ? this.originalNextSibling
+ : null;
+ this.originalParent.insertBefore(host, before);
+ }
+
+ /**
+ * Hands focus back to whatever opened the panel. The toolbar help button is
+ * still mounted and gets it; a "More in help" button lives in a popover that
+ * has already closed, so there is nothing to return to and focus is left alone.
+ */
+ private returnFocus(): void {
+ const trigger = this.focusOnOpen;
+ this.focusOnOpen = null;
+ if (trigger?.isConnected) {
+ trigger.focus();
+ }
+ }
+
+ onQueryChange(query: string): void {
+ this.query = query;
+ // A blank query browses instead of searching, so there is nothing to rank.
+ this.results = this.isSearching ? 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);
+ }
+
+ /**
+ * The controls a task touches. Filtered by the same admin rule the rest of the
+ * panel uses, so a link can never point at an entry the panel does not list.
+ */
+ related(id: HelpEntryId): HelpEntryId[] {
+ return (this.helpContent.entry(id)?.related ?? [])
+ .filter(relatedId => {
+ const entry = this.helpContent.entry(relatedId);
+ return !!entry && (!entry.adminOnly || this.isAdmin);
+ });
+ }
+
+ /**
+ * Follows a related link. Reuses the existing deep-link target rather than
+ * inventing panel-local navigation: open() expands the entry and scrolls to it.
+ * The query is cleared first, because a control the current search did not match
+ * has no row to scroll to while the result list is on screen.
+ *
+ * The surface is carried through deliberately: open() defaults it to 'page', so
+ * following a link from a panel opened inside the day-cell dialog would silently
+ * reset it and leave "Take the tour" replaying the page tour, whose anchors are
+ * all behind the dialog backdrop. Navigating within the panel does not move the
+ * planner to another surface.
+ */
+ openRelated(id: HelpEntryId): void {
+ this.onQueryChange('');
+ this.helpPanel.open(id, this.surface);
+ }
+
+ 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();
+ }
+
+ replayTour(): void {
+ // Read before closing: close() resets the surface back to the page.
+ const tour = this.surface;
+ this.helpPanel.close();
+ this.replayTourRequested.emit(tour);
+ }
+
+ 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);
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.html
new file mode 100644
index 00000000..6228b12a
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.html
@@ -0,0 +1,24 @@
+
+
+
+ {{ (state!.index + 1) }} / {{ state!.total }}
+
+
{{ tourProse.title }}
+
{{ tourProse.short }}
+
+ {{ ui.skip }}
+ {{ ui.next }}
+
+
+
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.scss b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.scss
new file mode 100644
index 00000000..45591302
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.scss
@@ -0,0 +1,56 @@
+.tp-help-tour {
+ width: 320px;
+ padding: 15px 16px 13px;
+ border: 1px solid var(--border, #e2e6e9);
+ border-radius: 9px;
+ background: var(--tp-td-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;
+ color: var(--text-header, #0f1316);
+ }
+
+ &__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;
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.spec.ts
new file mode 100644
index 00000000..34c26bcc
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.spec.ts
@@ -0,0 +1,297 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ElementRef } from '@angular/core';
+import { OverlayModule } from '@angular/cdk/overlay';
+import { TranslateService } from '@ngx-translate/core';
+import { of } from 'rxjs';
+import { HelpVisibilityService } from '../../services/help-visibility.service';
+import { HelpTourComponent } from './help-tour.component';
+
+/**
+ * The one dependency the help chrome gained when help became admin-only. A stub
+ * rather than a mock store: HelpVisibilityService is the only thing the chrome
+ * asks, so these specs do not need ngrx at all. It defaults to visible, so every
+ * assertion below still covers the admin case it was written for.
+ */
+const helpVisibility = { isVisible: true, isVisible$: of(true) };
+const provideHelpVisibility = { provide: HelpVisibilityService, useValue: helpVisibility };
+
+import { HelpTourService } from '../../services/help-tour.service';
+import { HelpTourName } from '../../help.model';
+import { enUS, enUSUi } from '../../i18n/enUS';
+
+describe('HelpTourComponent', () => {
+ const anchor = (id: string): HTMLElement => {
+ const element = document.createElement('div');
+ element.setAttribute('data-tp-help', id);
+ document.body.appendChild(element);
+ return element;
+ };
+
+ const mount = (tour: HelpTourName = 'page'): ComponentFixture => {
+ const fixture = TestBed.createComponent(HelpTourComponent);
+ fixture.componentInstance.tour = tour;
+ fixture.detectChanges();
+ return fixture;
+ };
+
+ const cards = (): HTMLElement[] =>
+ Array.from(document.querySelectorAll('.tp-help-tour'));
+ const card = (): HTMLElement | null => cards()[0] ?? null;
+
+ beforeEach(() => {
+ TestBed.resetTestingModule();
+ document.body.querySelectorAll('[data-tp-help]').forEach(element => element.remove());
+ localStorage.clear();
+ TestBed.configureTestingModule({
+ declarations: [HelpTourComponent],
+ imports: [OverlayModule],
+ providers: [
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ });
+ });
+
+ it('does not mark the tour seen just by being mounted', () => {
+ // state$ is a BehaviorSubject seeded null, so the subscription fires once at
+ // mount with a null state. Marking "seen" there would permanently suppress
+ // the automatic first run for every genuine first-time user.
+ mount();
+ expect(TestBed.inject(HelpTourService).hasSeen('page')).toBe(false);
+ expect(localStorage.getItem('tp.planning.tour.v1')).toBeNull();
+ });
+
+ it('shows no card while no tour is running', () => {
+ const fixture = mount();
+ expect(fixture.componentInstance.state).toBeNull();
+ expect(fixture.componentInstance.origin).toBeNull();
+ expect(card()).toBeNull();
+ });
+
+ it('renders only in the instance whose tour is running', () => {
+ // The service is a singleton and Task 9 mounts this component twice: once on
+ // the page, once inside the day-cell dialog. Without a per-instance filter
+ // both would render the same card at the same time.
+ anchor('toolbar.dateRange');
+ anchor('dayCell.plannedTimes');
+ const pageTour = mount('page');
+ const dialogTour = mount('dialog');
+
+ TestBed.inject(HelpTourService).start('dialog', { isAdmin: false });
+ pageTour.detectChanges();
+ dialogTour.detectChanges();
+
+ expect(dialogTour.componentInstance.state?.entry.id).toBe('dayCell.plannedTimes');
+ expect(pageTour.componentInstance.state).toBeNull();
+ expect(pageTour.componentInstance.origin).toBeNull();
+ expect(cards().length).toBe(1);
+ expect(card()!.querySelector('h5')!.textContent!.trim())
+ .toBe(enUS['dayCell.plannedTimes'].title);
+ });
+
+ it('renders the running step against its anchor', () => {
+ const element = anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ expect(fixture.componentInstance.state?.entry.id).toBe('toolbar.dateRange');
+ expect(fixture.componentInstance.origin).toBeInstanceOf(ElementRef);
+ expect(fixture.componentInstance.origin?.nativeElement).toBe(element);
+
+ const rendered = card();
+ expect(rendered).not.toBeNull();
+ expect(rendered!.querySelector('h5')!.textContent!.trim())
+ .toBe(enUS['toolbar.dateRange'].title);
+ expect(rendered!.querySelector('.tp-help-tour__body')!.textContent!.trim())
+ .toBe(enUS['toolbar.dateRange'].short);
+ });
+
+ it('names the dialog for screen readers without claiming to be modal', () => {
+ anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ mount();
+
+ const rendered = card()!;
+ expect(rendered.getAttribute('role')).toBe('dialog');
+ expect(rendered.getAttribute('aria-modal')).toBe('false');
+ const labelledBy = rendered.getAttribute('aria-labelledby');
+ expect(labelledBy).toBeTruthy();
+ expect(rendered.querySelector('h5')!.id).toBe(labelledBy);
+ });
+
+ it('gives two mounted instances distinct label ids', () => {
+ expect(mount('page').componentInstance.titleId)
+ .not.toBe(mount('dialog').componentInstance.titleId);
+ });
+
+ it('moves focus to Next so the card is reachable from the keyboard', () => {
+ // The overlay is appended at the end of , nowhere near the anchor in
+ // tab order, so without this the buttons are effectively unreachable.
+ anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ mount();
+
+ expect(document.activeElement).toBe(card()!.querySelector('.tp-help-tour__next'));
+ });
+
+ it('shows the step counter as one-based', () => {
+ anchor('toolbar.dateRange');
+ anchor('grid.openDay');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ expect(card()!.querySelector('.tp-help-tour__step')!.textContent!.replace(/\s+/g, ' ').trim())
+ .toBe('1 / 2');
+
+ fixture.componentInstance.next();
+ fixture.detectChanges();
+
+ expect(card()!.querySelector('.tp-help-tour__step')!.textContent!.replace(/\s+/g, ' ').trim())
+ .toBe('2 / 2');
+ });
+
+ it('labels its buttons from HelpUiStrings, never the shared translate catalogue', () => {
+ anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ mount();
+
+ expect(card()!.querySelector('.tp-help-tour__skip')!.textContent!.trim()).toBe(enUSUi.skip);
+ expect(card()!.querySelector('.tp-help-tour__next')!.textContent!.trim()).toBe(enUSUi.next);
+ });
+
+ it('advances to the next step when Next is clicked', () => {
+ anchor('toolbar.dateRange');
+ const second = anchor('grid.openDay');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ (card()!.querySelector('.tp-help-tour__next') as HTMLButtonElement).click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.state?.entry.id).toBe('grid.openDay');
+ expect(fixture.componentInstance.origin?.nativeElement).toBe(second);
+ });
+
+ it('closes and marks the tour seen when Skip is clicked', () => {
+ anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ (card()!.querySelector('.tp-help-tour__skip') as HTMLButtonElement).click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.state).toBeNull();
+ expect(card()).toBeNull();
+ expect(TestBed.inject(HelpTourService).hasSeen('page')).toBe(true);
+ });
+
+ it('skips the tour on Escape, and ignores other keys', () => {
+ anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ // Dispatched as a real event, not called on the instance: CDK's keyboard
+ // dispatcher listens on document.body and routes to the top overlay, so this
+ // proves the (overlayKeydown) binding exists, not just the handler method.
+ const press = (key: string) =>
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+
+ press('a');
+ fixture.detectChanges();
+ expect(fixture.componentInstance.state).not.toBeNull();
+
+ press('Escape');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.state).toBeNull();
+ expect(card()).toBeNull();
+ expect(TestBed.inject(HelpTourService).hasSeen('page')).toBe(true);
+ });
+
+ it('ends the tour when the current step\'s anchor leaves the DOM', () => {
+ // Anchors are only validated at start(). If the dialog closes mid-tour the
+ // overlay would go away while the tour stayed "running" — no card, no Skip,
+ // and Task 9's "start if not seen" logic would see a live tour forever.
+ const element = anchor('toolbar.dateRange');
+ anchor('grid.openDay');
+ const tour = TestBed.inject(HelpTourService);
+ tour.start('page', { isAdmin: false });
+ const fixture = mount();
+ expect(card()).not.toBeNull();
+
+ element.remove();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.state).toBeNull();
+ expect(fixture.componentInstance.origin).toBeNull();
+ expect(card()).toBeNull();
+ expect(tour.isRunning).toBe(false);
+ });
+
+ it('leaves an anchor-loss end unseen and still offerable, unlike a user skip', () => {
+ // The dialog tour starts the instant the day-cell dialog opens, so a planner
+ // who opens a row, glances and closes it may have seen one step of six.
+ // Closing a dialog means "done with this row", not "done learning".
+ const tour = TestBed.inject(HelpTourService);
+ let element = anchor('dayCell.plannedTimes');
+ tour.start('dialog', { isAdmin: false });
+ const fixture = mount('dialog');
+ expect(card()).not.toBeNull();
+
+ element.remove();
+ fixture.detectChanges();
+
+ expect(tour.isRunning).toBe(false);
+ expect(tour.hasSeen('dialog')).toBe(false);
+
+ // Still offerable: the same tour runs again and renders.
+ element = anchor('dayCell.plannedTimes');
+ tour.start('dialog', { isAdmin: false });
+ fixture.detectChanges();
+ expect(card()).not.toBeNull();
+
+ // The other direction: the user ending it does count.
+ (card()!.querySelector('.tp-help-tour__skip') as HTMLButtonElement).click();
+ fixture.detectChanges();
+
+ expect(card()).toBeNull();
+ expect(tour.hasSeen('dialog')).toBe(true);
+ });
+
+ it('re-points at a replaced anchor node rather than a detached one', () => {
+ const element = anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ element.remove();
+ const replacement = anchor('toolbar.dateRange');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.origin?.nativeElement).toBe(replacement);
+ expect(card()).not.toBeNull();
+ });
+
+ it('closes when the last step is passed', () => {
+ anchor('toolbar.dateRange');
+ TestBed.inject(HelpTourService).start('page', { isAdmin: false });
+ const fixture = mount();
+
+ fixture.componentInstance.next();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.state).toBeNull();
+ expect(fixture.componentInstance.origin).toBeNull();
+ expect(card()).toBeNull();
+ });
+
+ it('stops reflecting the tour once destroyed', () => {
+ anchor('toolbar.dateRange');
+ const tour = TestBed.inject(HelpTourService);
+ const fixture = mount();
+ fixture.destroy();
+
+ tour.start('page', { isAdmin: false });
+
+ expect(fixture.componentInstance.state).toBeNull();
+ });
+});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.ts
new file mode 100644
index 00000000..95d6964d
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-tour/help-tour.component.ts
@@ -0,0 +1,153 @@
+import {
+ AfterViewChecked,
+ Component,
+ DoCheck,
+ ElementRef,
+ Input,
+ OnDestroy,
+ OnInit,
+ ViewChild,
+} from '@angular/core';
+import { CdkConnectedOverlay, ConnectedPosition } from '@angular/cdk/overlay';
+import { Subscription } from 'rxjs';
+import { HelpProse, HelpTourName } from '../../help.model';
+import { HelpTourService, HelpTourState } from '../../services/help-tour.service';
+import { HelpChromeBase } from '../help-chrome.base';
+
+/** Distinguishes the aria-labelledby target of one mounted tour from another's. */
+let nextTourCardId = 0;
+
+@Component({
+ selector: 'tp-help-tour',
+ templateUrl: './help-tour.component.html',
+ styleUrls: ['./help-tour.component.scss'],
+ standalone: false,
+})
+export class HelpTourComponent extends HelpChromeBase
+ implements OnInit, DoCheck, AfterViewChecked, OnDestroy {
+ /**
+ * Which tour this instance renders. The service is a singleton and one page can
+ * mount this component twice — once for the page tour, once inside the day-cell
+ * dialog — so an instance must ignore state belonging to the other tour.
+ */
+ @Input() tour: HelpTourName = 'page';
+
+ state: HelpTourState | null = null;
+
+ /**
+ * The anchor is a raw element found by querySelector, so it is wrapped:
+ * cdkConnectedOverlayOrigin also accepts an Element, but an ElementRef keeps
+ * the binding's intent explicit and matches the sibling help components.
+ */
+ origin: ElementRef | null = null;
+
+ readonly titleId = `tp-help-tour-title-${nextTourCardId++}`;
+
+ readonly positions: ConnectedPosition[] = [
+ { originX: 'center', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 10 },
+ { originX: 'center', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -10 },
+ ];
+
+ @ViewChild(CdkConnectedOverlay) private connectedOverlay?: CdkConnectedOverlay;
+
+ private readonly subscriptions = new Subscription();
+ private pendingFocus = false;
+
+ /**
+ * The overlay uses CDK's reposition strategy, so an anchor below the fold
+ * yields a card pushed on screen pointing at nothing. Bring each step's anchor
+ * into view as it becomes current — once per step, so a re-rendered anchor node
+ * does not yank the page back mid-read.
+ */
+ private scrolledFor: string | null = null;
+
+ constructor(private helpTour: HelpTourService) {
+ super();
+ }
+
+ 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 => {
+ const mine = state && state.entry.tour === this.tour ? state : null;
+ this.pendingFocus = this.pendingFocus || (!!mine && !this.state);
+ this.state = mine;
+ this.setOrigin(mine ? this.helpTour.anchorElement(mine.entry) : null);
+ if (!mine) {
+ this.scrolledFor = null;
+ } else if (this.scrolledFor !== mine.entry.id) {
+ this.scrolledFor = mine.entry.id;
+ // Optional call: jsdom and other non-layout hosts do not implement it.
+ this.origin?.nativeElement.scrollIntoView?.({ block: 'center', inline: 'center' });
+ }
+ }));
+ }
+
+ /**
+ * Anchors are only validated when the tour starts. If the current step's anchor
+ * leaves the DOM afterwards — the day-cell dialog closes mid-tour, a filter hides
+ * the worker select — the overlay would close while the tour stayed "running",
+ * leaving no card and no way to skip. End the tour instead. A replaced (rather
+ * than removed) anchor node just re-points the overlay.
+ *
+ * abort(), not stop(): the page changed underneath the tour, which says nothing
+ * about whether the user is done with it, so it stays offerable.
+ */
+ ngDoCheck(): void {
+ if (!this.state) {
+ return;
+ }
+ const element = this.helpTour.anchorElement(this.state.entry);
+ if (element) {
+ this.setOrigin(element);
+ } else {
+ this.helpTour.abort();
+ }
+ }
+
+ ngAfterViewChecked(): void {
+ if (!this.pendingFocus) {
+ return;
+ }
+ // The overlay is appended at the end of , far from the anchor in tab
+ // order, so a keyboard user cannot otherwise reach Skip or Next. No focus
+ // trap: the tour is non-modal and must not lock the page it is explaining.
+ const primaryAction = this.connectedOverlay?.overlayRef?.overlayElement
+ ?.querySelector('.tp-help-tour__next');
+ if (primaryAction) {
+ this.pendingFocus = false;
+ primaryAction.focus();
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.subscriptions.unsubscribe();
+ }
+
+ next(): void {
+ this.helpTour.next();
+ }
+
+ skip(): void {
+ this.helpTour.stop();
+ }
+
+ onOverlayKeydown(event: KeyboardEvent): void {
+ if (this.state && event.key === 'Escape') {
+ this.skip();
+ }
+ }
+
+ private setOrigin(element: HTMLElement | null): void {
+ if (this.origin?.nativeElement === element) {
+ return;
+ }
+ this.origin = element ? new ElementRef(element) : null;
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/grid-help-anchors.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/grid-help-anchors.ts
new file mode 100644
index 00000000..a1215b34
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/grid-help-anchors.ts
@@ -0,0 +1,34 @@
+import { isDevMode } from '@angular/core';
+import { PLANNING_HELP_ENTRIES } from './planning-help.registry';
+
+/**
+ * mtx-grid renders its own header row, and MtxGridColumn has no per-column
+ * header template, so the Name column's sort header cannot carry a
+ * `data-tp-help` attribute from a template the way every other anchor does.
+ * Stamp it after the grid has rendered instead.
+ */
+export const GRID_NAME_HEADER_SELECTOR = 'th.mat-column-siteName';
+
+/** Read from the registry rather than repeated, so the stamp cannot drift from it. */
+const SORT_NAME_ANCHOR = PLANNING_HELP_ENTRIES.find(entry => entry.id === 'grid.sortName')?.anchor;
+
+let warnedAboutMissingAnchor = false;
+
+export function applyGridHelpAnchors(root: ParentNode | null | undefined): void {
+ if (!SORT_NAME_ANCHOR) {
+ // A stamp that quietly stops happening is worse than one that complains:
+ // the registry would still advertise an anchor nothing produces.
+ if (isDevMode() && !warnedAboutMissingAnchor) {
+ warnedAboutMissingAnchor = true;
+ console.warn(
+ '[tp-help] grid.sortName has no anchor in PLANNING_HELP_ENTRIES; '
+ + 'the Name column header will not be stamped.',
+ );
+ }
+ return;
+ }
+ const header = root?.querySelector(GRID_NAME_HEADER_SELECTOR);
+ if (header && !header.hasAttribute('data-tp-help')) {
+ header.setAttribute('data-tp-help', SORT_NAME_ANCHOR);
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts
new file mode 100644
index 00000000..37102fb6
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts
@@ -0,0 +1,494 @@
+import { readdirSync, readFileSync } from 'fs';
+import { dirname, join } from 'path';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { OverlayContainer, OverlayModule } from '@angular/cdk/overlay';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { TranslateService } from '@ngx-translate/core';
+import { of } from 'rxjs';
+import { PLANNING_HELP_ENTRIES } from './planning-help.registry';
+import { HelpPanelComponent } from './components/help-panel/help-panel.component';
+import { HelpTourComponent } from './components/help-tour/help-tour.component';
+import { HelpPanelService } from './services/help-panel.service';
+import { HelpTourService } from './services/help-tour.service';
+import { applyGridHelpAnchors } from './grid-help-anchors';
+import { HelpVisibilityService } from './services/help-visibility.service';
+import { enUSUi } from './i18n/enUS';
+
+const MODULE_ROOT = join(__dirname, '..');
+
+/** Help is admin-only; the components under test need the flag, not a whole store. */
+const provideHelpVisibility = {
+ provide: HelpVisibilityService,
+ useValue: { isVisible: true, isVisible$: of(true) },
+};
+
+const CONTAINER_HTML = 'components/plannings/time-plannings-container/time-plannings-container.component.html';
+const CONTAINER_TS = 'components/plannings/time-plannings-container/time-plannings-container.component.ts';
+const TABLE_HTML = 'components/plannings/time-plannings-table/time-plannings-table.component.html';
+const TABLE_TS = 'components/plannings/time-plannings-table/time-plannings-table.component.ts';
+const DIALOG_HTML = 'components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html';
+const DIALOG_TS = 'components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts';
+const DIALOG_SCSS = 'components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.scss';
+
+const read = (relative: string): string => readFileSync(join(MODULE_ROOT, relative), 'utf8');
+
+const MARKUP = [read(CONTAINER_HTML), read(TABLE_HTML), read(DIALOG_HTML)].join('\n');
+
+/**
+ * Every literal `'key' | translate` in the three wired templates, frozen as it
+ * stood before the help system was mounted. The help chrome takes its labels
+ * from HelpUiStrings, so this task added none; a new entry here means someone
+ * added a key that has to be translated into all 25 shared locale files.
+ */
+const TEMPLATE_TRANSLATE_KEYS = [
+ 'Actual', 'Auto break calculation', 'Cancel', 'CommentOffice', 'CommentWorker', 'Date range',
+ 'Download Excel', 'Export to payroll', 'Flex', 'Flex balance at start of day',
+ 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'Needs update!', 'NettoHours',
+ 'NettoHours override', 'No pay rule set selected', 'PaidOutFlex', 'Pause', 'Plan hours',
+ 'Planned working hours', 'Reload table', 'Reset pause to recorded', 'Save',
+ 'Shift not stopped by user!', 'Shifts across midnight', 'Show resigned', 'Start', 'Stop',
+ 'Tags', 'Total breaktime', 'Total working hours', 'Use 1-minute intervals',
+ 'View GPS Location', 'View history', 'View Snapshot', 'Worker', 'Worktime start',
+ 'Worktime stop',
+];
+
+describe('help wiring', () => {
+ it('anchors every entry that a tour needs', () => {
+ const tourEntries = PLANNING_HELP_ENTRIES.filter(entry => entry.tourStep !== undefined);
+ // Guards the loop below: an empty registry would make it pass vacuously.
+ expect(tourEntries.length).toBeGreaterThan(0);
+ for (const entry of tourEntries) {
+ 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(entry => entry.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)).toBe(true);
+ }
+ });
+
+ it('only uses anchors that exist in the registry', () => {
+ const known = new Set(PLANNING_HELP_ENTRIES.map(entry => entry.anchor).filter(Boolean));
+ const used = [...MARKUP.matchAll(/data-tp-help="([^"]+)"/g)].map(match => match[1]);
+ expect(used.length).toBeGreaterThan(0);
+ 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', () => {
+ expect(read(CONTAINER_TS)).toMatch(/start\(\s*'page'/);
+ expect(read(DIALOG_TS)).toMatch(/start\(\s*'dialog'/);
+ });
+
+ it('ends the dialog tour with abort, never stop, when the dialog goes away', () => {
+ // stop() marks the tour seen. Closing a row is not "I have seen the tour".
+ const dialogTs = read(DIALOG_TS);
+ expect(dialogTs).toMatch(/helpTour\w*\.abort\(\)/);
+ expect(dialogTs).not.toMatch(/helpTour\w*\.stop\(\)/);
+ });
+
+ 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(read).join('\n');
+ expect(helpTemplates).not.toContain('| translate');
+ });
+
+ it('binds the help-icon and panel outputs, or they are inert', () => {
+ // Every icon, not just one: an unbound "More in help" is a control that
+ // silently does nothing.
+ const icons = (MARKUP.match(/ {
+ // A hint has no trigger and no dismiss, so it is only ever right where the
+ // page itself decides to show it: an empty grid, a validation error, a
+ // disabled future day. grid.nameColumn used to be a fourth hint here and was
+ // a banner across the top of the page from load until navigation — it is an
+ // icon now, asserted below.
+ //
+ // The list is exhaustive, which is what keeps that banner from coming back:
+ // any hint re-added anywhere in the three templates fails here, whatever
+ // attributes it is written with.
+ const hints = [...MARKUP.matchAll(//g)].map(match => match[0]);
+ const hintIds = hints
+ .map(hint => /helpId="([^"]+)"/.exec(hint)?.[1])
+ .filter((id): id is string => !!id);
+ expect(hintIds.sort()).toEqual([
+ 'dayCell.futureDisabled', 'dayCell.planHoursLimit', 'grid.noWorkers',
+ ]);
+ });
+
+ it('shows the plan-hours hint with the validation error, not always', () => {
+ const dialogHtml = read(DIALOG_HTML);
+ const hint = /]*helpId="dayCell.planHoursLimit"[\s\S]*?><\/tp-help-hint>|<\/tp-help-hint>/
+ .exec(dialogHtml);
+ expect(hint).not.toBeNull();
+ expect((hint as RegExpExecArray)[0]).toContain("hasError('tooManyHours')");
+ // Beside the error it explains, not somewhere else in the form.
+ expect(dialogHtml.indexOf('helpId="dayCell.planHoursLimit"'))
+ .toBeGreaterThan(dialogHtml.indexOf('data-testid="planHours-Error"'));
+ });
+
+ it('renders the empty-grid hint where the grid renders no rows', () => {
+ // mtx-grid swaps noResultTemplate in for the row area when `data` is empty;
+ // dropped anywhere else the hint would be a permanent banner.
+ const tableHtml = read(TABLE_HTML);
+ expect(tableHtml).toContain('[noResultTemplate]="noWorkersTemplate"');
+ const template = /([\s\S]*?)<\/ng-template>/.exec(tableHtml);
+ expect(template).not.toBeNull();
+ expect((template as RegExpExecArray)[1]).toContain('helpId="grid.noWorkers"');
+ });
+
+ it('offers the name column as a clickable icon above the grid, never as a banner', () => {
+ const tableHtml = read(TABLE_HTML);
+ // An icon: opened by a click and dismissed by one. As a hint it was open
+ // from page load and had no way to close, so it sat across the top of the
+ // planning page permanently.
+ const affordance = /]*helpId="grid\.nameColumn"|`
+ // would satisfy a bare 'tp-help-icon' substring.
+ expect((affordance as RegExpExecArray)[0]).toContain(' it reads as a footnote on the table rather than as
+ // something about the column it describes.
+ expect(tableHtml.indexOf('helpId="grid.nameColumn"'))
+ .toBeLessThan(tableHtml.indexOf(' {
+ const containerHtml = read(CONTAINER_HTML);
+ const button = /]*id="planningHelp"[\s\S]*?>/.exec(containerHtml);
+ expect(button).not.toBeNull();
+ // mat-icon-button sizes from Material's state-layer variables and fights the
+ // shared class the other five toolbar buttons use on their own.
+ expect((button as RegExpExecArray)[0]).not.toContain('mat-icon-button');
+ expect((button as RegExpExecArray)[0])
+ .toContain('class="btn-secondary btn-secondary--icon-rounded-border"');
+ });
+
+ it('gates the help button on the same source as the rest of the help chrome', () => {
+ // Not the container's own `isAdmin`. That is a take(1) read, so it cannot
+ // follow a later flip to non-admin — the cross-tab storage listener
+ // re-dispatching, a role change — and would leave a ? button on screen that
+ // opens a panel refusing to render. The service is live.
+ const containerHtml = read(CONTAINER_HTML);
+ const button = /]*id="planningHelp"[\s\S]*?>/.exec(containerHtml);
+ expect(button).not.toBeNull();
+ expect((button as RegExpExecArray)[0])
+ .toContain('*ngIf="helpVisibility.isVisible$ | async"');
+ expect((button as RegExpExecArray)[0]).not.toMatch(/\*ngIf="isAdmin/);
+ // The binding needs the field to exist, or the template silently reads undefined.
+ expect(read(CONTAINER_TS)).toContain('inject(HelpVisibilityService)');
+ });
+
+ it('does not introduce new translate keys for help chrome', () => {
+ // The help chrome must come from HelpUiStrings. This freezes the literal
+ // translate keys the three wired templates use: adding `'Help' | translate`,
+ // or any other new key, fails here and forces a deliberate update of all 25
+ // shared locale files.
+ const used = [...MARKUP.matchAll(/'([^']+)'\s*\|\s*translate/g)].map(match => match[1]);
+ expect(new Set(used)).toEqual(new Set(TEMPLATE_TRANSLATE_KEYS));
+ });
+
+ it('does not add the help chrome to the shared locale catalogue', () => {
+ // Wording that could only have come from this feature. Generic labels the
+ // plugin already translates ('Close', 'Next') are deliberately not listed.
+ const distinctive = [
+ enUSUi.searchHelp, enUSUi.moreInHelp, enUSUi.replayTour, enUSUi.noResults,
+ enUSUi.sectionTask, enUSUi.sectionDayCell,
+ ];
+ const localeDir = join(MODULE_ROOT, 'i18n');
+ const localeFiles = readdirSync(localeDir).filter(name => name.endsWith('.ts'));
+ expect(localeFiles.length).toBeGreaterThan(20);
+ for (const name of localeFiles) {
+ const contents = readFileSync(join(localeDir, name), 'utf8');
+ for (const phrase of distinctive) {
+ expect(contents).not.toContain(phrase);
+ }
+ }
+ });
+
+ it('keeps the help-paired form fields at the width they had', () => {
+ // These five were direct children of the .d-flex.flex-column column, where a
+ // flex item stretches. Rowing them up with their icon shrinks them to
+ // mat-form-field's intrinsic width unless the stretch is restored.
+ const dialogHtml = read(DIALOG_HTML);
+ expect((dialogHtml.match(/class="field-with-help"/g) ?? []).length).toBe(5);
+
+ const scss = read(DIALOG_SCSS);
+ const start = scss.indexOf('.field-with-help {');
+ expect(start).toBeGreaterThan(-1);
+ const block = scss.slice(start, scss.indexOf('\n}', start));
+ expect(block).toContain('mat-form-field');
+ expect(block).toMatch(/flex:\s*1 1 auto/);
+ });
+
+ it('marks the day-cell dialog body scrollable so popovers dismiss on its scroll', () => {
+ // ScrollDispatcher only watches containers carrying cdkScrollable. The dialog
+ // declares its own overflow container next to Material's mat-dialog-content.
+ expect(read(DIALOG_HTML)).toMatch(/class="main-content"[^>]*cdkScrollable|cdkScrollable[^>]*class="main-content"/);
+ });
+
+ it('stamps the name-column sort header, which mtx-grid renders itself', () => {
+ const root = document.createElement('div');
+ root.innerHTML = '
Name
'
+ + '
Mon
';
+
+ applyGridHelpAnchors(root);
+
+ expect(root.querySelector('th.mat-column-siteName')?.getAttribute('data-tp-help'))
+ .toBe('grid.sortName');
+ expect(root.querySelector('th.mat-column-0')?.hasAttribute('data-tp-help')).toBe(false);
+ });
+
+ it('warns instead of stopping silently if the registry drops the sortName anchor', () => {
+ jest.isolateModules(() => {
+ jest.doMock('./planning-help.registry', () => ({ PLANNING_HELP_ENTRIES: [] }));
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { applyGridHelpAnchors: stamp } = require('./grid-help-anchors');
+
+ const root = document.createElement('div');
+ const header = document.createElement('th');
+ header.className = 'mat-column-siteName';
+ root.appendChild(header);
+
+ stamp(root);
+
+ expect(header.hasAttribute('data-tp-help')).toBe(false);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('grid.sortName'));
+ warn.mockRestore();
+ });
+ jest.dontMock('./planning-help.registry');
+ });
+
+ it('calls the header stamp from the table component', () => {
+ // Importing it is not calling it: the stamp only lands from a render hook.
+ const tableTs = read(TABLE_TS);
+ const hook = /ngAfterViewChecked\(\)[\s\S]*?\n \}/.exec(tableTs);
+ expect(hook).not.toBeNull();
+ expect((hook as RegExpExecArray)[0]).toContain('applyGridHelpAnchors(this.el.nativeElement)');
+ });
+
+ it('raises the panel above the CDK overlay layer it is parked in', () => {
+ // .cdk-overlay-container is a stacking context, and inside it CDK puts the
+ // backdrop, the global wrapper and every pane on one z-index. Below that the
+ // panel paints under the day-cell dialog's backdrop, is dimmed by it and
+ // loses hit-testing to it — a click on the panel would reach the backdrop
+ // and close the dialog. The CDK value is read, not assumed.
+ const cdkCss = readFileSync(
+ join(dirname(require.resolve('@angular/cdk/package.json')), 'overlay-prebuilt.css'), 'utf8');
+ const cdkLayers = [...cdkCss.matchAll(
+ /\.cdk-(?:overlay-backdrop|overlay-pane|global-overlay-wrapper)\{[^}]*?z-index:\s*(\d+)/g)]
+ .map(match => Number(match[1]));
+ expect(cdkLayers.length).toBeGreaterThan(0);
+
+ const panelScss = read('help/components/help-panel/help-panel.component.scss');
+ const block = panelScss.slice(0, panelScss.indexOf('\n}'));
+ const declared = /z-index:\s*(\d+)/.exec(block);
+ expect(declared).not.toBeNull();
+
+ expect(Number((declared as RegExpExecArray)[1])).toBeGreaterThan(Math.max(...cdkLayers));
+ // .cdk-overlay-container is pointer-events: none; panes opt back in one by one.
+ expect(block).toContain('pointer-events: auto');
+ });
+});
+
+describe('help deep link and tour scrolling', () => {
+ const scrolled: Element[] = [];
+ let originalScrollIntoView: unknown;
+
+ beforeAll(() => {
+ originalScrollIntoView = (Element.prototype as any).scrollIntoView;
+ (Element.prototype as any).scrollIntoView = function (this: Element) {
+ scrolled.push(this);
+ };
+ });
+
+ afterAll(() => {
+ (Element.prototype as any).scrollIntoView = originalScrollIntoView;
+ });
+
+ beforeEach(() => {
+ scrolled.length = 0;
+ TestBed.resetTestingModule();
+ document.body.querySelectorAll('[data-tp-help]').forEach(element => element.remove());
+ localStorage.clear();
+ });
+
+ const mountPanel = (): ComponentFixture => {
+ TestBed.configureTestingModule({
+ declarations: [HelpPanelComponent],
+ imports: [FormsModule, MatIconModule, MatButtonModule],
+ providers: [
+ HelpPanelService,
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ });
+ const fixture = TestBed.createComponent(HelpPanelComponent);
+ fixture.detectChanges();
+ return fixture;
+ };
+
+ it('scrolls the panel to a deep-link target, and only then', () => {
+ const fixture = mountPanel();
+ const panel = TestBed.inject(HelpPanelService);
+
+ // Browsing the whole catalogue has nothing to scroll to.
+ panel.open();
+ fixture.detectChanges();
+ expect(scrolled).toHaveLength(0);
+
+ // flex.sumFlex sits in the last section, well below the fold of a panel that
+ // opens scrolled to the top.
+ panel.close();
+ fixture.detectChanges();
+ panel.open('flex.sumFlex');
+ fixture.detectChanges();
+
+ const target = fixture.nativeElement.querySelector('.tp-help-entry--target') as HTMLElement;
+ expect(target).not.toBeNull();
+ expect(scrolled).toEqual([target]);
+ });
+
+ it('parks the open panel inside the CDK overlay container and gives it focus', () => {
+ // CDK's Dialog marks body siblings of the overlay container aria-hidden
+ // while a modal is open, and the day-cell dialog links into this panel.
+ const fixture = mountPanel();
+ const host = fixture.nativeElement as HTMLElement;
+ const container = TestBed.inject(OverlayContainer).getContainerElement();
+ const panel = TestBed.inject(HelpPanelService);
+
+ expect(host.parentNode).not.toBe(container);
+
+ panel.open();
+ fixture.detectChanges();
+
+ expect(host.parentNode).toBe(container);
+ expect(document.activeElement)
+ .toBe(host.querySelector('.tp-help-panel__search input'));
+
+ panel.close();
+ fixture.detectChanges();
+
+ expect(host.parentNode).not.toBe(container);
+ });
+
+ it('puts the host back where it was, not merely back in the parent', () => {
+ // Appending on close walks the panel down past its siblings — in the real
+ // template, past — a little further on every open/close.
+ const fixture = mountPanel();
+ const host = fixture.nativeElement as HTMLElement;
+ const marker = document.createElement('div');
+ (host.parentNode as Node).appendChild(marker);
+ const panel = TestBed.inject(HelpPanelService);
+
+ for (let cycle = 0; cycle < 2; cycle++) {
+ panel.open();
+ fixture.detectChanges();
+ panel.close();
+ fixture.detectChanges();
+ }
+
+ expect(host.nextSibling).toBe(marker);
+ });
+
+ it('takes focus when it opens, but not when an already-open panel is deep-linked', () => {
+ const fixture = mountPanel();
+ const host = fixture.nativeElement as HTMLElement;
+ const trigger = document.createElement('button');
+ document.body.appendChild(trigger);
+ trigger.focus();
+ const panel = TestBed.inject(HelpPanelService);
+
+ panel.open();
+ fixture.detectChanges();
+ const search = host.querySelector('.tp-help-panel__search input') as HTMLElement;
+ expect(document.activeElement).toBe(search);
+
+ // The planner clicks into the list; a second "More in help" must not yank
+ // focus back to the search box.
+ const entryHead = host.querySelector('.tp-help-entry__head') as HTMLElement;
+ entryHead.focus();
+ panel.open('flex.sumFlex');
+ fixture.detectChanges();
+ expect(document.activeElement).toBe(entryHead);
+
+ // Closing hands focus back to whatever opened it.
+ panel.close();
+ fixture.detectChanges();
+ expect(document.activeElement).toBe(trigger);
+
+ trigger.remove();
+ });
+
+ it('scrolls the current tour anchor into view when a step becomes current', () => {
+ TestBed.configureTestingModule({
+ declarations: [HelpTourComponent],
+ imports: [OverlayModule],
+ providers: [
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ });
+ const anchors = new Map();
+ for (const entry of PLANNING_HELP_ENTRIES.filter(e => e.tour === 'page' && e.tourStep !== undefined)) {
+ const element = document.createElement('div');
+ element.setAttribute('data-tp-help', entry.anchor as string);
+ document.body.appendChild(element);
+ anchors.set(entry.anchor as string, element);
+ }
+
+ const fixture: ComponentFixture = TestBed.createComponent(HelpTourComponent);
+ fixture.componentInstance.tour = 'page';
+ fixture.detectChanges();
+
+ const tour = TestBed.inject(HelpTourService);
+ tour.start('page', { isAdmin: true });
+ fixture.detectChanges();
+
+ const first = anchors.get('toolbar.dateRange') as HTMLElement;
+ expect(scrolled).toContain(first);
+
+ scrolled.length = 0;
+ tour.next();
+ fixture.detectChanges();
+
+ expect(scrolled).toContain(anchors.get('toolbar.navForward') as HTMLElement);
+ expect(scrolled).not.toContain(first);
+ });
+});
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..52b3cd27
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts
@@ -0,0 +1,86 @@
+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',
+ 'grid.noWorkers',
+ // 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',
+ 'dayCell.planHoursLimit',
+ // 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;
+ /** Plural noun for the search result count, e.g. "12 results". */
+ resultCount: string;
+ /** Singular noun for a one-hit search, e.g. "1 result". */
+ resultCountOne: string;
+ /** Heading above the controls a task points at. */
+ relatedControls: string;
+ /** Accessible names for the kind badge on a search result. */
+ kindTask: string;
+ kindControl: 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/da.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.spec.ts
new file mode 100644
index 00000000..5609bc7b
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.spec.ts
@@ -0,0 +1,85 @@
+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 day-type copy is covered by day-type-copy.spec.ts, which asserts the
+ // same thing properly: that ONE sentence carries the warning about the look-alike
+ // type, in every registered locale. The version that used to stand here checked
+ // only that five substrings appeared somewhere in the entry, all of which the
+ // neutral type-by-type listing already guarantees — and 'Ferie' is a substring of
+ // 'Ferie fridag' — so deleting the warning sentence left it green. Removed rather
+ // than hardened, because hardening it would have reproduced the other spec.
+
+ 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..53cb9177
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts
@@ -0,0 +1,410 @@
+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. 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': {
+ 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'],
+ },
+ 'grid.noWorkers': {
+ title: 'Listen er tom',
+ short: 'Ingen medarbejder passer til filtrene over skemaet. Siden har data; det er udvalget, der ikke giver nogen rækker.',
+ detail: 'Ryd tagfilteret, sæt medarbejderfilteret tilbage til alle medarbejdere, eller udvid datointervallet. Vis fratrådte begrænser også listen: en medarbejder, der er stoppet, bliver ved med at være skjult, indtil den er slået til. Er listen stadig tom, når alle filtre er ryddet, er der ingen medarbejder sat op i den valgte periode.',
+ keywords: ['tom', 'ingen rækker', 'ingen medarbejdere', 'intet vises', 'tomt skema', 'mangler medarbejder', 'filter', 'ingen resultater', 'listen er tom', 'hvor er mine medarbejdere'],
+ },
+
+ // ---------------------------------------------------------- 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 — 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': {
+ 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. 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': {
+ 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'],
+ },
+ 'dayCell.planHoursLimit': {
+ title: 'Planlagte timer kan ikke overstige 24',
+ short: 'De planlagte timer på én dag skal på tværs af alle vagter give 24 timer eller mindre. Indtil de gør det, står feltet med fejl, og Gem er slået fra.',
+ detail: 'Se på den planlagte start og slut for hver vagt på dagen: det er summen, der måles, ikke den enkelte vagt. En vagt, der går over midnat, hører delvist til den næste dag, så læg timerne efter midnat på den dag i stedet for at samle dem her.',
+ keywords: ['24 timer', 'overstiger', 'for mange timer', 'planlagte timer', 'validering', 'fejl', 'rød', 'gem slået fra', 'kan ikke gemme', 'over 24', 'grænse'],
+ },
+
+ // ------------------------------------------------------------------ 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:',
+ resultCount: 'resultater',
+ resultCountOne: 'resultat',
+ relatedControls: 'Funktioner, den bruger',
+ kindTask: 'Opgave',
+ kindControl: 'Funktion',
+ 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/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..ce142c67
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/day-type-copy.spec.ts
@@ -0,0 +1,99 @@
+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];
+ /** 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". */
+ zeroHours: RegExp;
+ }
+
+ const EXPECTED: Record = {
+ '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,
+ },
+ };
+
+ 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(' ');
+ };
+
+ // 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} names both day types that set the day to zero hours`, () => {
+ const text = flagsText(locale);
+ for (const zeroing of expected.zeroing) {
+ expect(text).toContain(zeroing);
+ }
+ 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', () => {
+ 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
new file mode 100644
index 00000000..8bb893ce
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts
@@ -0,0 +1,406 @@
+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 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': {
+ 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: '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': {
+ 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'],
+ },
+ 'grid.noWorkers': {
+ title: 'The list is empty',
+ short: 'No worker matched the filters above the table. The page has data; this selection has no rows.',
+ detail: 'Clear the tag filter, set the worker filter back to all workers, or widen the date range. Show resigned also narrows the list: a worker who has left stays hidden until it is on. If the list is still empty with every filter cleared, no worker is set up for these dates.',
+ keywords: ['empty', 'no rows', 'no workers', 'nothing shown', 'blank table', 'missing worker', 'filter', 'no results', 'list is empty', 'where are my workers'],
+ },
+
+ // -------------------------------------------------------------- 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 — 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': {
+ 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. 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': {
+ 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'],
+ },
+ 'dayCell.planHoursLimit': {
+ title: 'Planned hours cannot exceed 24',
+ short: 'The planned hours on one day, across every shift, have to add up to 24 or less. Until they do, the field is in error and Save stays disabled.',
+ detail: 'Check the planned start and stop of each shift on the day: the total is what is being measured, not any single shift. A shift that runs past midnight belongs partly to the next day, so plan the hours after midnight on that day instead of stacking them onto this one.',
+ keywords: ['24 hours', 'exceed', 'too many hours', 'planned hours', 'validation', 'error', 'red', 'save disabled', 'cannot save', 'over 24', 'limit'],
+ },
+
+ // ----------------------------------------------------------------- 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:',
+ resultCount: 'results',
+ resultCountOne: 'result',
+ relatedControls: 'Controls this uses',
+ kindTask: 'Task',
+ kindControl: 'Control',
+ 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..6be0b5b9
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/index.ts
@@ -0,0 +1,17 @@
+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;
+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..aeef9210
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.spec.ts
@@ -0,0 +1,91 @@
+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('ends the page tour by inviting the user to open a day', () => {
+ // The tour's closing move is meant to hand the planner the thing they came to
+ // do, not an export they may never touch.
+ const pageSteps = PLANNING_HELP_ENTRIES
+ .filter(e => e.tour === 'page' && e.tourStep !== undefined)
+ .sort((a, b) => (a.tourStep as number) - (b.tourStep as number));
+ expect(pageSteps.length).toBeGreaterThan(1);
+ expect(pageSteps[pageSteps.length - 1].id).toBe('grid.openDay');
+ });
+
+ 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..2d3a5fee
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts
@@ -0,0 +1,89 @@
+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', 'dayCell.planHoursLimit'] },
+ { id: 'task.payOutFlex', kind: 'task', section: 'task',
+ related: ['dayCell.paidOutFlex', 'flex.sumFlex'] },
+ { id: 'task.exportForPayroll', kind: 'task', section: 'task',
+ related: ['toolbar.downloadExcel', 'toolbar.payrollExport'] },
+ { 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: 6 },
+ { id: 'toolbar.payrollExport', kind: 'control', section: 'toolbar', anchor: 'toolbar.payrollExport',
+ tour: 'page', tourStep: 7, 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: 8 },
+ { id: 'grid.noWorkers', kind: 'control', section: 'grid', anchor: 'grid.noWorkers' },
+
+ // ---- 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' },
+ { id: 'dayCell.planHoursLimit', kind: 'control', section: 'dayCell', anchor: 'dayCell.planHoursLimit' },
+
+ // ---- 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' },
+];
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/playwright-tour-seed.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/playwright-tour-seed.spec.ts
new file mode 100644
index 00000000..91cb0795
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/playwright-tour-seed.spec.ts
@@ -0,0 +1,52 @@
+import { existsSync, readFileSync } from 'fs';
+import { join } from 'path';
+import { TOUR_STORAGE_KEY } from './services/help-tour.service';
+
+/**
+ * The planning page starts its onboarding tours automatically the first time a
+ * planner lands on it, which is exactly what every Playwright context looks like:
+ * a fresh profile with empty localStorage. An unseeded run therefore paints a tour
+ * card over the top grid rows and, inside the day-cell dialog, over the shift-1
+ * fields — and Playwright's actionability check fails on an intercepting overlay,
+ * turning the whole e2e matrix red.
+ *
+ * The seed below is what prevents that. It is easy to delete by accident and its
+ * link to the app is a bare string, so this test asserts both halves: the file
+ * exists and names the key HelpTourService actually reads, and the config points
+ * at the file.
+ */
+const CLIENT_ROOT = join(__dirname, '..', '..', '..', '..', '..', '..');
+const SEED_RELATIVE = 'playwright/helpers/tour-seen.storage.json';
+const SEED_PATH = join(CLIENT_ROOT, SEED_RELATIVE);
+const CONFIG_PATH = join(CLIENT_ROOT, 'playwright.config.ts');
+
+interface StorageStateFile {
+ origins?: { origin: string; localStorage?: { name: string; value: string }[] }[];
+}
+
+describe('playwright tour seed', () => {
+ it('ships a storage-state file where the config and CI expect it', () => {
+ expect(existsSync(SEED_PATH)).toBe(true);
+ });
+
+ it('seeds the key HelpTourService reads, for the origin the config runs against', () => {
+ const config = readFileSync(CONFIG_PATH, 'utf8');
+ const baseUrl = /baseURL:\s*'([^']+)'/.exec(config);
+ expect(baseUrl).not.toBeNull();
+
+ const seed = JSON.parse(readFileSync(SEED_PATH, 'utf8')) as StorageStateFile;
+ const origin = (seed.origins ?? [])
+ .find(candidate => candidate.origin === (baseUrl as RegExpExecArray)[1]);
+ expect(origin).toBeDefined();
+
+ const entry = (origin?.localStorage ?? []).find(item => item.name === TOUR_STORAGE_KEY);
+ expect(entry).toBeDefined();
+ // The value has to list both tours, or the surface it omits still auto-starts.
+ expect(JSON.parse((entry as { value: string }).value).sort()).toEqual(['dialog', 'page']);
+ });
+
+ it('wires the seed into the config, or it is a file nothing reads', () => {
+ const config = readFileSync(CONFIG_PATH, 'utf8');
+ expect(config).toMatch(new RegExp(`storageState:\\s*'${SEED_RELATIVE}'`));
+ });
+});
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..f787f2e4
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts
@@ -0,0 +1,107 @@
+import { TestBed } from '@angular/core/testing';
+import { TranslateService } from '@ngx-translate/core';
+import { HelpContentService } from './help-content.service';
+import { enUS, enUSUi } from '../i18n/enUS';
+import { da, daUi } from '../i18n/da';
+import { HELP_LOCALES } from '../i18n';
+import { HelpProseMap } from '../help.model';
+
+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')).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'];
+ }
+ });
+
+ // ui() resolves the chrome labels the same way prose() resolves content, and it
+ // is the only source of help chrome: the components must never reach for the 25
+ // shared ngx-translate locale files. Untested, a resolution bug here would show
+ // up as an English panel inside a Danish page.
+ it('returns English chrome labels for an English locale', () => {
+ expect(make('en-US').ui()).toBe(enUSUi);
+ });
+
+ it('resolves a bare language code to that locale\'s chrome labels', () => {
+ expect(make('da').ui()).toBe(daUi);
+ });
+
+ it('resolves a regional code to its bare language, before falling back', () => {
+ // ngx-translate reports whatever the account is set to; 'da-DK' has no map of
+ // its own and must land on Danish rather than on English.
+ expect(make('da-DK').ui()).toBe(daUi);
+ });
+
+ it('falls back to English chrome labels for a locale with no map', () => {
+ expect(make('de-DE').ui()).toBe(enUSUi);
+ });
+
+ it('falls back to English chrome labels when no locale is reported at all', () => {
+ expect(make('').ui()).toBe(enUSUi);
+ });
+
+ 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';
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-panel.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-panel.service.spec.ts
new file mode 100644
index 00000000..9fc4697e
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-panel.service.spec.ts
@@ -0,0 +1,65 @@
+import { firstValueFrom } from 'rxjs';
+import { HelpEntryId } from '../help.model';
+import { HelpPanelService } from './help-panel.service';
+
+describe('HelpPanelService', () => {
+ let service: HelpPanelService;
+
+ beforeEach(() => {
+ service = new HelpPanelService();
+ });
+
+ it('starts closed with no target', async () => {
+ expect(await firstValueFrom(service.isOpen$)).toBe(false);
+ expect(await firstValueFrom(service.target$)).toBeNull();
+ });
+
+ it('opens with no target', async () => {
+ 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 () => {
+ service.open('flex.sumFlex');
+
+ expect(await firstValueFrom(service.isOpen$)).toBe(true);
+ expect(await firstValueFrom(service.target$)).toBe('flex.sumFlex');
+
+ service.close();
+
+ expect(await firstValueFrom(service.isOpen$)).toBe(false);
+ expect(await firstValueFrom(service.target$)).toBeNull();
+ });
+
+ it('replaces the target when opened again on a different entry', async () => {
+ service.open('flex.sumFlex');
+ service.open('toolbar.dateRange');
+
+ expect(await firstValueFrom(service.target$)).toBe('toolbar.dateRange');
+ });
+
+ it('drops the previous target when reopened without one', async () => {
+ service.open('flex.sumFlex');
+ service.open();
+
+ expect(await firstValueFrom(service.target$)).toBeNull();
+ });
+
+ it('pushes every state change to subscribers, current value first', () => {
+ const open: boolean[] = [];
+ const targets: (HelpEntryId | null)[] = [];
+ const openSub = service.isOpen$.subscribe(value => open.push(value));
+ const targetSub = service.target$.subscribe(value => targets.push(value));
+
+ service.open('flex.sumFlex');
+ service.close();
+
+ expect(open).toEqual([false, true, false]);
+ expect(targets).toEqual([null, 'flex.sumFlex', null]);
+
+ openSub.unsubscribe();
+ targetSub.unsubscribe();
+ });
+});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-panel.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-panel.service.ts
new file mode 100644
index 00000000..6f9c99a8
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-panel.service.ts
@@ -0,0 +1,40 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { HelpEntryId, HelpTourName } from '../help.model';
+
+/**
+ * Open/close state for the help side panel. It is a service rather than component
+ * state so that anything on the page — a help icon deep in the grid, a toolbar
+ * button, the tour — can open the panel without owning it.
+ */
+@Injectable({ providedIn: 'root' })
+export class HelpPanelService {
+ private readonly openState = new BehaviorSubject(false);
+ private readonly targetState = new BehaviorSubject(null);
+ private readonly surfaceState = new BehaviorSubject('page');
+
+ readonly isOpen$: Observable = this.openState.asObservable();
+ readonly target$: Observable = this.targetState.asObservable();
+
+ /**
+ * Which tour belongs to the surface the panel was last opened from. There is one
+ * panel for the whole page, and it can be opened from the toolbar or from inside
+ * the day-cell dialog; "Take the tour" has to replay the tour of the surface the
+ * planner is actually looking at, or it points at anchors behind the dialog
+ * backdrop and leaves a card nobody can reach.
+ */
+ readonly surface$: Observable = this.surfaceState.asObservable();
+
+ /** Opens the panel, optionally scrolled to and expanded on one entry. */
+ open(target?: HelpEntryId, surface: HelpTourName = 'page'): void {
+ this.surfaceState.next(surface);
+ this.targetState.next(target ?? null);
+ this.openState.next(true);
+ }
+
+ close(): void {
+ this.openState.next(false);
+ this.targetState.next(null);
+ this.surfaceState.next('page');
+ }
+}
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..f93ac26e
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.spec.ts
@@ -0,0 +1,103 @@
+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 results = make('da').search('ferie', { isAdmin: false });
+ // `fallback` distinguishes a real hit from the task list search hands back
+ // when nothing matched. Without this guard the assertion below would also
+ // pass on a no-match, because task.registerVacation is one of the twelve
+ // tasks in that consolation list.
+ expect(results.some(r => !r.fallback)).toBe(true);
+ expect(results.map(r => r.entry.id)).toContain('task.registerVacation');
+ });
+
+ it('finds a Danish entry from an English word, through the fallback', () => {
+ const results = make('da').search('vacation', { isAdmin: false });
+ expect(results.some(r => !r.fallback)).toBe(true);
+ // Asserted on a CONTROL, which the no-match task list can never contain, and
+ // on one whose Danish prose does not carry the English word: the only route
+ // to it is HELP_FALLBACK in HelpSearchService.rank(). Remove that candidate
+ // and this goes red.
+ expect(results.map(r => r.entry.id)).toContain('dayCell.flags');
+ });
+
+ it('folds diacritics so ae matches æ', () => {
+ const service = make('da');
+ 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');
+ // ø: 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));
+ // å: 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', () => {
+ 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);
+ expect(results.every(r => r.fallback === true)).toBe(true);
+ });
+
+ it('returns the task list for an empty query', () => {
+ const results = make('en-US').search(' ', { isAdmin: false });
+ // [].every() is true, so without a length guard this passes on the exact bug
+ // it is here to catch: a blank query returning nothing at all.
+ expect(results.length).toBeGreaterThan(0);
+ expect(results.every(r => r.entry.kind === 'task')).toBe(true);
+ expect(results.every(r => r.fallback === true)).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..cbbd7d9e
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.ts
@@ -0,0 +1,90 @@
+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;
+ /**
+ * True when this result is part of the task list handed back because the query
+ * matched nothing (or was empty), rather than a match on the query itself. A
+ * help search must never dead-end, so the caller shows these — but it has to be
+ * able to say so instead of passing them off as hits.
+ */
+ fallback?: boolean;
+}
+
+/** 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), fallback: true }));
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.spec.ts
new file mode 100644
index 00000000..300f0fcc
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.spec.ts
@@ -0,0 +1,324 @@
+import { TestBed } from '@angular/core/testing';
+import { TranslateService } from '@ngx-translate/core';
+import { firstValueFrom, of } from 'rxjs';
+import { HelpTourService, TOUR_STORAGE_KEY } from './help-tour.service';
+import { HelpContentService } from './help-content.service';
+import { HelpVisibilityService } from './help-visibility.service';
+
+/**
+ * The one dependency the help chrome gained when help became admin-only. A stub
+ * rather than a mock store: HelpVisibilityService is the only thing the chrome
+ * asks, so these specs do not need ngrx at all. It defaults to visible, so every
+ * assertion below still covers the admin case it was written for.
+ */
+const helpVisibility = { isVisible: true, isVisible$: of(true) };
+const provideHelpVisibility = { provide: HelpVisibilityService, useValue: helpVisibility };
+
+
+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(() => {
+ // The stub is shared by every case here; the gate tests flip it.
+ helpVisibility.isVisible = true;
+ document.body.innerHTML = '';
+ localStorage.clear();
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ HelpTourService,
+ HelpContentService,
+ { provide: TranslateService, useValue: { currentLang: 'en-US' } },
+ provideHelpVisibility,
+ ],
+ });
+ service = TestBed.inject(HelpTourService);
+ });
+
+ it('is idle before it starts', async () => {
+ expect(await firstValueFrom(service.state$)).toBeNull();
+ expect(service.isRunning).toBe(false);
+ });
+
+ 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);
+ expect(service.isRunning).toBe(true);
+ });
+
+ 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');
+ expect(state?.index).toBe(1);
+ });
+
+ it('walks the steps in tourStep order, not registry order', async () => {
+ // Registry order puts navForward (step 2) before dateRange (step 1).
+ anchor('toolbar.navForward');
+ anchor('toolbar.dateRange');
+ service.start('page', { isAdmin: false });
+ expect((await firstValueFrom(service.state$))?.entry.id).toBe('toolbar.dateRange');
+ service.next();
+ expect((await firstValueFrom(service.state$))?.entry.id).toBe('toolbar.navForward');
+ });
+
+ it('offers the payroll step to an admin whose anchor exists', async () => {
+ anchor('toolbar.payrollExport');
+ anchor('toolbar.dateRange');
+ service.start('page', { isAdmin: true });
+ const state = await firstValueFrom(service.state$);
+ expect(state?.total).toBe(2);
+ service.next();
+ expect((await firstValueFrom(service.state$))?.entry.id).toBe('toolbar.payrollExport');
+ });
+
+ 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('runs the dialog tour independently of the page tour', async () => {
+ anchor('toolbar.dateRange');
+ anchor('dayCell.plannedTimes');
+ service.start('dialog', { isAdmin: false });
+ const state = await firstValueFrom(service.state$);
+ expect(state?.entry.id).toBe('dayCell.plannedTimes');
+ expect(state?.total).toBe(1);
+ });
+
+ it('ends after the last step', async () => {
+ anchor('toolbar.dateRange');
+ service.start('page', { isAdmin: false });
+ service.next();
+ expect(await firstValueFrom(service.state$)).toBeNull();
+ expect(service.isRunning).toBe(false);
+ });
+
+ 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('ends a tour without recording it when the page changes underneath', () => {
+ // abort() is the environmental path: an anchor vanished, which says nothing
+ // about whether the user is done. stop() is the user saying so.
+ anchor('toolbar.dateRange');
+ service.start('page', { isAdmin: false });
+ service.abort();
+
+ expect(service.isRunning).toBe(false);
+ expect(service.hasSeen('page')).toBe(false);
+ });
+
+ it('records the same tour as seen when the user stops it instead', () => {
+ anchor('toolbar.dateRange');
+ service.start('page', { isAdmin: false });
+ service.abort();
+ expect(service.hasSeen('page')).toBe(false);
+
+ service.start('page', { isAdmin: false });
+ service.stop();
+ expect(service.hasSeen('page')).toBe(true);
+ });
+
+ it('marks only the tour that ran, leaving the other one unseen', () => {
+ anchor('dayCell.plannedTimes');
+ service.start('dialog', { isAdmin: false });
+ service.next();
+ expect(service.hasSeen('dialog')).toBe(true);
+ expect(service.hasSeen('page')).toBe(false);
+ });
+
+ 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('leaves a tour that never started unseen even after stop()', () => {
+ // emit() already cleared `current` because no step was shown, so stop() has
+ // nothing to record. This covers emit()'s guard, not a second guard in stop().
+ service.start('page', { isAdmin: false });
+ service.stop();
+ expect(service.hasSeen('page')).toBe(false);
+ });
+
+ it('skips a step whose anchor vanished after the tour started', async () => {
+ anchor('toolbar.dateRange');
+ anchor('toolbar.navForward');
+ anchor('grid.openDay');
+ service.start('page', { isAdmin: false });
+ expect((await firstValueFrom(service.state$))?.total).toBe(3);
+
+ document.querySelector('[data-tp-help="toolbar.navForward"]')!.remove();
+ service.next();
+
+ const state = await firstValueFrom(service.state$);
+ expect(state?.entry.id).toBe('grid.openDay');
+ // The vanished step is dropped rather than counted toward a card never shown.
+ expect(state?.total).toBe(2);
+ expect(state?.index).toBe(1);
+ });
+
+ it('still marks the tour seen when the last remaining anchors vanish', () => {
+ anchor('toolbar.dateRange');
+ anchor('grid.openDay');
+ service.start('page', { isAdmin: false });
+
+ document.querySelector('[data-tp-help="grid.openDay"]')!.remove();
+ service.next();
+
+ expect(service.isRunning).toBe(false);
+ expect(service.hasSeen('page')).toBe(true);
+ });
+
+ it('does not re-mark or throw when stopped twice', () => {
+ anchor('toolbar.dateRange');
+ service.start('page', { isAdmin: false });
+ service.stop();
+ expect(() => service.stop()).not.toThrow();
+ expect(JSON.parse(localStorage.getItem(TOUR_STORAGE_KEY) as string)).toEqual(['page']);
+ });
+
+ 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('keeps a previously seen tour when a second one is marked', () => {
+ service.markSeen('page');
+ service.markSeen('dialog');
+ expect(JSON.parse(localStorage.getItem(TOUR_STORAGE_KEY) as string).sort())
+ .toEqual(['dialog', 'page']);
+ });
+
+ it('reads what an earlier session stored', () => {
+ localStorage.setItem(TOUR_STORAGE_KEY, JSON.stringify(['dialog']));
+ expect(service.hasSeen('dialog')).toBe(true);
+ expect(service.hasSeen('page')).toBe(false);
+ });
+
+ it('treats unparsable stored state as nothing seen', () => {
+ localStorage.setItem(TOUR_STORAGE_KEY, 'not json');
+ expect(service.hasSeen('page')).toBe(false);
+ });
+
+ 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();
+ });
+
+ it('survives localStorage rejecting a write', () => {
+ const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('quota');
+ });
+ expect(() => service.markSeen('page')).not.toThrow();
+ setItem.mockRestore();
+ });
+
+ it('resolves the anchor element for an entry, and null when it is gone', () => {
+ anchor('toolbar.dateRange');
+ const entry = TestBed.inject(HelpContentService).entry('toolbar.dateRange');
+ expect(service.anchorElement(entry!)).toBe(
+ document.querySelector('[data-tp-help="toolbar.dateRange"]'),
+ );
+ document.body.innerHTML = '';
+ expect(service.anchorElement(entry!)).toBeNull();
+ });
+
+ it('has no anchor element for an entry that declares none', () => {
+ const task = TestBed.inject(HelpContentService).entry('task.registerVacation');
+ expect(task?.anchor).toBeUndefined();
+ expect(service.anchorElement(task!)).toBeNull();
+ });
+
+ it('restarts cleanly from step one', async () => {
+ anchor('toolbar.dateRange');
+ anchor('toolbar.navForward');
+ service.start('page', { isAdmin: false });
+ service.next();
+ expect((await firstValueFrom(service.state$))?.index).toBe(1);
+ service.start('page', { isAdmin: false });
+ expect((await firstValueFrom(service.state$))?.index).toBe(0);
+ });
+
+ it('refuses to start for a non-admin, and does not mark the tour seen', () => {
+ anchor('toolbar.dateRange');
+ anchor('toolbar.navForward');
+ helpVisibility.isVisible = false;
+
+ service.start('page', { isAdmin: true });
+
+ expect(service.isRunning).toBe(false);
+ // Not seen: the gate is temporary. A planner who was never offered the tour
+ // has not declined it and must still get it the first time help appears.
+ expect(service.hasSeen('page')).toBe(false);
+
+ // The same call runs for an admin, so the two assertions above are about the
+ // gate and not about missing anchors.
+ helpVisibility.isVisible = true;
+ service.start('page', { isAdmin: true });
+ expect(service.isRunning).toBe(true);
+ });
+
+ it('still offers a refused tour once help becomes visible', async () => {
+ anchor('toolbar.dateRange');
+ helpVisibility.isVisible = false;
+ service.start('page', { isAdmin: true });
+ // Without this the case passes with the gate deleted: an ungated start would
+ // run, hasSeen would still be false (the tour was neither finished nor
+ // skipped) and the second start would simply restart at index 0.
+ expect(service.isRunning).toBe(false);
+
+ helpVisibility.isVisible = true;
+ expect(service.hasSeen('page')).toBe(false);
+ service.start('page', { isAdmin: true });
+ expect((await firstValueFrom(service.state$))?.index).toBe(0);
+ });
+});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.ts
new file mode 100644
index 00000000..fb8553f7
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.ts
@@ -0,0 +1,156 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { HelpEntry, HelpTourName } from '../help.model';
+import { HelpContentService } from './help-content.service';
+import { HelpVisibilityService } from './help-visibility.service';
+
+export const TOUR_STORAGE_KEY = 'tp.planning.tour.v1';
+
+export interface HelpTourState {
+ entry: HelpEntry;
+ index: number;
+ total: number;
+}
+
+/**
+ * Sequences the guided tour. The steps come from the registry in tourStep order;
+ * this service decides which of them can actually be shown and where it is up to.
+ */
+@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,
+ private helpVisibility: HelpVisibilityService,
+ ) {}
+
+ /**
+ * Steps whose anchor is absent are dropped, never treated as an error: the
+ * payroll-export control only renders for Microting staff, and the worker
+ * filter only renders when the account has more than one site.
+ */
+ start(tour: HelpTourName, opts: { isAdmin: boolean }): void {
+ // The one choke point for all three ways a tour begins: startPageTourOnce,
+ // startDialogTourOnce and the panel's replay button. Refusing here closes
+ // every path at once.
+ //
+ // A refused tour is deliberately NOT marked seen. The gate is temporary; a
+ // planner who never got the offer has not declined it, and must still be
+ // offered it the first time help becomes visible to them.
+ if (!this.helpVisibility.isVisible) {
+ return;
+ }
+ 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();
+ }
+
+ /**
+ * Ends the tour because the user said so — Skip or Escape — which counts as
+ * having seen it. `current` is only set while a step is actually on screen;
+ * emit() clears it the moment a tour ends or fails to start, so no further
+ * guard is needed here.
+ */
+ stop(): void {
+ if (this.current) {
+ this.markSeen(this.current);
+ }
+ this.reset();
+ }
+
+ /**
+ * Ends the tour because the page changed underneath it — an anchor vanished —
+ * WITHOUT recording it as seen. That is an environmental interruption, not a
+ * signal from the user: the dialog tour starts the instant the day-cell dialog
+ * opens, so a planner who opens a row, glances and closes it may have seen one
+ * step of six. Closing a dialog means "done with this row", not "done learning",
+ * and the tour must still be offered automatically next time.
+ */
+ abort(): void {
+ this.reset();
+ }
+
+ /** 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 reset(): void {
+ this.current = null;
+ this.steps = [];
+ this.index = 0;
+ this.stateSubject.next(null);
+ }
+
+ private emit(): void {
+ // A step's anchor can disappear after start() validated it — a filter hides
+ // the worker select, the day-cell dialog closes. Drop those steps as they are
+ // reached, so `total` stays honest rather than counting a card that never shows.
+ while (this.index < this.steps.length && !this.anchorElement(this.steps[this.index])) {
+ this.steps.splice(this.index, 1);
+ }
+ 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.
+ }
+ }
+}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-visibility.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-visibility.service.spec.ts
new file mode 100644
index 00000000..ad21eb06
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-visibility.service.spec.ts
@@ -0,0 +1,65 @@
+import { TestBed } from '@angular/core/testing';
+import { Store } from '@ngrx/store';
+import { BehaviorSubject, firstValueFrom } from 'rxjs';
+import { HelpVisibilityService } from './help-visibility.service';
+
+describe('HelpVisibilityService', () => {
+ let isAdmin: BehaviorSubject;
+
+ const build = (): HelpVisibilityService => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ HelpVisibilityService,
+ { provide: Store, useValue: { select: () => isAdmin } },
+ ],
+ });
+ return TestBed.inject(HelpVisibilityService);
+ };
+
+ beforeEach(() => {
+ isAdmin = new BehaviorSubject(undefined);
+ });
+
+ it('hides help until the store says the user is an admin', () => {
+ const service = build();
+ expect(service.isVisible).toBe(false);
+ });
+
+ it('shows help once the admin flag arrives after construction', async () => {
+ // The reason this service subscribes rather than taking a single value. The
+ // planning container is built before the auth state has necessarily landed;
+ // a take(1) read here would latch the `undefined` above and hide every help
+ // surface from a real admin for the rest of the session, with nothing on
+ // screen to explain it and nothing in the code that looks wrong.
+ const service = build();
+ expect(service.isVisible).toBe(false);
+
+ isAdmin.next(true);
+
+ expect(service.isVisible).toBe(true);
+ expect(await firstValueFrom(service.isVisible$)).toBe(true);
+ });
+
+ it('hides help again if the flag goes away', () => {
+ const service = build();
+ isAdmin.next(true);
+ isAdmin.next(false);
+ expect(service.isVisible).toBe(false);
+ });
+
+ it('replays the current value to a late subscriber', async () => {
+ // The components read it synchronously in a getter, but anything binding
+ // isVisible$ subscribes after the fact and must not wait for the next change.
+ const service = build();
+ isAdmin.next(true);
+ expect(await firstValueFrom(service.isVisible$)).toBe(true);
+ });
+
+ it('stops listening to the store when it is torn down', () => {
+ const service = build();
+ expect(isAdmin.observed).toBe(true);
+ service.ngOnDestroy();
+ expect(isAdmin.observed).toBe(false);
+ });
+});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-visibility.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-visibility.service.ts
new file mode 100644
index 00000000..ef3c3708
--- /dev/null
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-visibility.service.ts
@@ -0,0 +1,45 @@
+import { inject, Injectable, OnDestroy } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { selectCurrentUserIsAdmin } from 'src/app/state';
+
+/**
+ * The single switch that decides whether any help chrome exists at all.
+ *
+ * The whole help system is admin-only for now — not the ? button, not the panel,
+ * not either tour, not one ⓘ, not one inline hint. Gating that at the eighteen
+ * call sites spread over three templates would be eighteen chances to miss one,
+ * so every surface asks this service instead: HelpEntryChromeBase for the icons
+ * and hints, HelpPanelComponent for the panel, HelpTourService for both tours.
+ *
+ * `selectCurrentUserIsAdmin` is the selector the planning container, the day-cell
+ * dialog and the rest of this plugin family already standardise on.
+ *
+ * Subscribed live, deliberately, and never with take(1): the admin flag is not
+ * necessarily in the store when the planning page is constructed, and a one-shot
+ * read that happens to land first would latch `false` and hide help from an
+ * admin for the rest of the session — a silent, unreproducible disappearance.
+ */
+@Injectable({ providedIn: 'root' })
+export class HelpVisibilityService implements OnDestroy {
+ private readonly store = inject(Store);
+ private readonly visible = new BehaviorSubject(false);
+ private readonly subscription: Subscription;
+
+ constructor() {
+ this.subscription = this.store.select(selectCurrentUserIsAdmin)
+ .subscribe(isAdmin => this.visible.next(!!isAdmin));
+ }
+
+ /** For templates and anything that wants to react to the flag arriving. */
+ readonly isVisible$: Observable = this.visible.asObservable();
+
+ /** For the synchronous guards — a prose getter, a start() that must refuse now. */
+ get isVisible(): boolean {
+ return this.visible.value;
+ }
+
+ ngOnDestroy(): void {
+ this.subscription.unsubscribe();
+ }
+}
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..a34ce89c 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,12 @@ 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 {CdkScrollable} from '@angular/cdk/scrolling';
+import {HelpIconComponent} from './help/components/help-icon/help-icon.component';
+import {HelpHintComponent} from './help/components/help-hint/help-hint.component';
+import {HelpPanelComponent} from './help/components/help-panel/help-panel.component';
+import {HelpTourComponent} from './help/components/help-tour/help-tour.component';
@NgModule({
imports: [
@@ -94,7 +100,12 @@ import {MtxSelect} from '@ng-matero/extensions/select';
MatStartDate,
MatEndDate,
MatPrefix,
- MatError
+ MatError,
+ OverlayModule,
+ // The day-cell dialog declares its own overflow container inside
+ // mat-dialog-content; ScrollDispatcher only sees containers marked
+ // cdkScrollable, and the help popovers dismiss on scroll.
+ CdkScrollable
],
declarations: [
TimePlanningPnLayoutComponent,
@@ -106,6 +117,10 @@ import {MtxSelect} from '@ng-matero/extensions/select';
TimePlanningsTableComponent,
TimePlanningsContainerComponent,
PayrollExportDialogComponent,
+ HelpIconComponent,
+ HelpHintComponent,
+ HelpPanelComponent,
+ HelpTourComponent,
],
providers: [
TimePlanningPnSettingsService,