From 8de306c2215282be581f159bbe076a0ff831285e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 3 Oct 2026 07:04:14 +0200 Subject: [PATCH 1/2] feat(plannings): the day dialog lists each shift's pause start and stop times (#1741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web showed only pause totals, although the DTO already carries the recorded Pause{N}StartedAt/StoppedAt stamps. The day dialog now lists each shift's pauses as read-only clock ranges ("Pause 10:02–10:17"), built by buildPauseSegments, which applies the rules of the backend's BuildMergedPauseSegments per shift. A pause that is still running is shown open-ended. A shift with a pause override lists nothing, because its served stamps are synthesized. Co-Authored-By: Claude Opus 5.5 --- .../s/day-dialog-pause-times.spec.ts | 76 ++++++++++++++++ .../plannings/pause-segments.util.spec.ts | 51 +++++++++++ .../plannings/pause-segments.util.ts | 88 +++++++++++++++++++ .../workday-entity-dialog.component.html | 8 ++ .../workday-entity-dialog.component.spec.ts | 43 +++++++++ .../workday-entity-dialog.component.ts | 13 +++ 6 files changed, 279 insertions(+) create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/day-dialog-pause-times.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/day-dialog-pause-times.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/day-dialog-pause-times.spec.ts new file mode 100644 index 00000000..a18eb4a5 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/day-dialog-pause-times.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; +import { + closeDayWithoutChange, lastWeekMonday, openDay, useLastWeekDashboard, waitForSpinner, +} from './reconcile-helpers'; + +/** + * #1741: the day dialog lists each shift's recorded pauses as clock ranges. + * + * The stamps come from the app, which this suite cannot drive, so the grid's index + * response is given two shift-1 pauses (the primary slot and a sub-slot, out of + * order) on one day, and every other pause slot of that day is emptied. Nothing is + * written to the database. That the backend serves the stamps is covered by the C# + * tests of the index; this covers what the web does with them. + */ + +const INDEX_GLOB = '**/api/time-planning-pn/plannings/index'; +const DAY = 2; +const API_TIMEOUT = 30000; +const UI_TIMEOUT = 15000; + +test.describe('Day dialog: recorded pause times', () => { + const session = useLastWeekDashboard(); + + test('lists both pauses of shift 1 as clock ranges, in clock order', async ({ page }) => { + const worker = await session.pickWorker(page, 0); + + // Set by the route, so a reload that patched nothing fails here and not as a + // missing list in the dialog. + let patchedRows = 0; + await page.route(INDEX_GLOB, async route => { + const response = await route.fetch(); + const body = await response.json(); + for (const row of body?.model ?? []) { + const day = row.planningPrDayModels?.[DAY]; + if (!day?.id) { + continue; + } + const date = `${day.date}`.slice(0, 10); + for (const key of Object.keys(day)) { + if (/^pause\d+(StartedAt|StoppedAt)$/.test(key)) { + day[key] = null; + } + } + day.pause1StartedAt = `${date}T12:30:00Z`; + day.pause1StoppedAt = `${date}T12:45:00Z`; + day.pause10StartedAt = `${date}T10:02:00Z`; + day.pause10StoppedAt = `${date}T10:17:00Z`; + day.pause1OverrideMinutes = null; + patchedRows++; + } + await route.fulfill({ response, json: body }); + }); + + const monday = lastWeekMonday(); + const reload = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/index') + && r.request().method() === 'POST' + && `${r.request().postDataJSON()?.dateFrom ?? ''}`.startsWith(monday), + { timeout: API_TIMEOUT }); + await page.locator('#workingHoursReload').click(); + expect((await reload).status(), 'the grid reload (POST plannings/index)').toBeLessThan(400); + await waitForSpinner(page); + expect(patchedRows, `the reload must carry a registration on day ${DAY} to patch`).toBeGreaterThan(0); + + await openDay(page, worker, DAY); + + const segments = page.locator('mat-dialog-container [data-testid="pauseSegments1"] [data-testid="pauseSegment1"]'); + await expect(segments).toHaveCount(2, { timeout: UI_TIMEOUT }); + await expect(segments.nth(0)).toContainText('10:02–10:17'); + await expect(segments.nth(1)).toContainText('12:30–12:45'); + // Shift 2 recorded no pause, so it lists none. + await expect(page.locator('mat-dialog-container [data-testid="pauseSegments2"]')).toHaveCount(0); + + await closeDayWithoutChange(page); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts new file mode 100644 index 00000000..aceb3b8e --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts @@ -0,0 +1,51 @@ +import { buildPauseSegments } from './pause-segments.util'; + +describe('buildPauseSegments', () => { + it('lists nothing for a shift with no pause stamps', () => { + expect(buildPauseSegments([])).toEqual([]); + expect(buildPauseSegments([[null, null], [undefined, undefined]])).toEqual([]); + }); + + it('lists the pauses in clock order whatever slot they were recorded in', () => { + expect(buildPauseSegments([ + ['2026-05-15T12:30:00Z', '2026-05-15T12:45:00Z'], + [null, null], + ['2026-05-15T10:02:40Z', '2026-05-15T10:17:10Z'], + ])).toEqual([ + { start: '10:02', stop: '10:17' }, + { start: '12:30', stop: '12:45' }, + ]); + }); + + it('merges overlapping and touching pauses, as the backend does before cutting them out', () => { + expect(buildPauseSegments([ + ['2026-05-15T10:00:00Z', '2026-05-15T10:20:00Z'], + ['2026-05-15T10:10:00Z', '2026-05-15T10:15:00Z'], + ['2026-05-15T10:20:00Z', '2026-05-15T10:30:00Z'], + ])).toEqual([{ start: '10:00', stop: '10:30' }]); + }); + + it('lists a pause that is still running open-ended, and does not merge it', () => { + expect(buildPauseSegments([ + ['2026-05-15T14:05:00Z', null], + ['2026-05-15T10:00:00Z', '2026-05-15T10:15:00Z'], + ])).toEqual([ + { start: '10:00', stop: '10:15' }, + { start: '14:05', stop: null }, + ]); + }); + + it('clamps a pause running past midnight to 24:00, as the backend does', () => { + expect(buildPauseSegments([ + ['2026-05-15T23:50:00Z', '2026-05-16T00:10:00Z'], + ])).toEqual([{ start: '23:50', stop: '24:00' }]); + }); + + it('skips a stop without a start and a pause that does not end after it starts', () => { + expect(buildPauseSegments([ + [null, '2026-05-15T10:15:00Z'], + ['2026-05-15T11:00:00Z', '2026-05-15T11:00:00Z'], + ['2026-05-15T12:00:00Z', '2026-05-15T11:50:00Z'], + ])).toEqual([]); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts new file mode 100644 index 00000000..a6c8778b --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts @@ -0,0 +1,88 @@ +/** One pause as shown on the web: wall-clock 'HH:mm', stop null while it is running. */ +export interface PauseSegment { + start: string; + stop: string | null; +} + +const SECONDS_PER_DAY = 86400; + +/** + * Seconds-of-day of a stamp. The stamps are wall-clock times serialised as UTC, the + * same reading the grid and the dialog give them through DatePipe(..., 'UTC'). + */ +function secondOfDay(stamp: Date): number { + return stamp.getUTCHours() * 3600 + stamp.getUTCMinutes() * 60 + stamp.getUTCSeconds(); +} + +function utcDay(stamp: Date): number { + return Date.UTC(stamp.getUTCFullYear(), stamp.getUTCMonth(), stamp.getUTCDate()); +} + +function toHhmm(seconds: number): string { + const minutes = Math.floor(seconds / 60); + return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; +} + +function parse(value: string | null | undefined): Date | null { + if (!value) { + return null; + } + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; +} + +/** + * The pauses of one shift as the web shows them, built from its + * Pause{N}StartedAt / Pause{N}StoppedAt slot pairs. + * + * Closed pauses follow the rules of the backend's BuildMergedPauseSegments + * (TimePlanningWorkingHoursService), applied per shift rather than across the whole + * day as the backend does, because the dialog shows them under each shift: + * - each pair resolves like ResolveShiftSeconds: a stop on a later day is clamped to + * 24:00, and a pair whose stop is not after its start is dropped; + * - the rest are sorted by start, and overlapping or touching pauses are merged. + * + * A pause with a start and no stop is still running. The backend has nothing to cut + * for it, but the admin should see it, so it is listed open-ended and never merged. + * A stop with no start says nothing and is skipped. + */ +export function buildPauseSegments( + pairs: ReadonlyArray, +): PauseSegment[] { + const closed: Array<{ start: number; stop: number }> = []; + const open: number[] = []; + + for (const [rawStart, rawStop] of pairs) { + const start = parse(rawStart); + if (!start) { + continue; + } + const startSec = secondOfDay(start); + const stop = parse(rawStop); + if (!stop) { + open.push(startSec); + continue; + } + const stopSec = utcDay(stop) > utcDay(start) ? SECONDS_PER_DAY : secondOfDay(stop); + if (stopSec > startSec) { + closed.push({ start: startSec, stop: stopSec }); + } + } + + closed.sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; stop: number }> = []; + for (const current of closed) { + const last = merged[merged.length - 1]; + if (last && current.start <= last.stop) { + last.stop = Math.max(last.stop, current.stop); + } else { + merged.push({ ...current }); + } + } + + const segments: Array<{ start: number; stop: number | null }> = + [...merged, ...open.map(start => ({ start, stop: null }))]; + return segments + .sort((a, b) => a.start - b.start) + .map(s => ({ start: toHhmm(s.start), stop: s.stop === null ? null : toHhmm(s.stop) })); +} 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 539ca640..f88b521c 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 @@ -273,6 +273,14 @@ [attr.data-testid]="'pause' + shiftId + 'Id-Error'"> {{ error }} +
+ + {{ 'Pause' | translate }} {{ segment.start }}–{{ segment.stop ?? '…' }} + +
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 fcf7ab3a..092a9b9b 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 @@ -532,6 +532,49 @@ describe('WorkdayEntityDialogComponent', () => { }); }); + describe('Recorded pause times (#1741)', () => { + // mockData is shared by reference across the suite, so every stamp and override + // this block writes is put back afterwards. + const touched = ['pause1StartedAt', 'pause1StoppedAt', 'pause10StartedAt', 'pause10StoppedAt', + 'pause2StartedAt', 'pause2StoppedAt', 'pause2OverrideMinutes']; + afterEach(() => { + const m = component.data.planningPrDayModels as any; + for (const key of touched) { + m[key] = null; + } + }); + + it('lists each shift\'s pauses as clock ranges, sub-slots included', () => { + const m = component.data.planningPrDayModels as any; + m.pause1StartedAt = '2026-05-15T12:30:00Z'; + m.pause1StoppedAt = '2026-05-15T12:45:00Z'; + m.pause10StartedAt = '2026-05-15T10:02:00Z'; + m.pause10StoppedAt = '2026-05-15T10:17:00Z'; + m.pause2StartedAt = '2026-05-15T19:00:00Z'; + m.pause2StoppedAt = null; + + component.ngOnInit(); + + expect(component.pauseSegments[1]).toEqual([ + { start: '10:02', stop: '10:17' }, + { start: '12:30', stop: '12:45' }, + ]); + expect(component.pauseSegments[2]).toEqual([{ start: '19:00', stop: null }]); + expect(component.pauseSegments[3]).toEqual([]); + }); + + it('lists nothing for a shift whose pause is overridden, as its stamps are synthesized', () => { + const m = component.data.planningPrDayModels as any; + m.pause2StartedAt = '2026-05-15T14:00:00Z'; + m.pause2StoppedAt = '2026-05-15T14:30:00Z'; + m.pause2OverrideMinutes = 30; + + component.ngOnInit(); + + expect(component.pauseSegments[2]).toEqual([]); + }); + }); + describe('Flag Change Handling', () => { it('should turn off other flags when one is turned on', () => { component.ngOnInit(); 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 0fcf7c13..be3b444c 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 @@ -22,6 +22,7 @@ import {HelpEntryId} from '../../../../help/help.model'; import {HelpPanelService} from '../../../../help/services/help-panel.service'; import {HelpTourService} from '../../../../help/services/help-tour.service'; import {assertLockOutcomeHandled, dayKey, formatReconciledProvenance, sendLockRequest} from '../../day-lock.util'; +import {buildPauseSegments, PauseSegment} from '../../pause-segments.util'; import { AbstractControl, @@ -132,6 +133,12 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { private pauseOverrideClearedMinutes: { [shift: number]: number | null } = {}; todaysFlex = 0; nettoHoursOverrideActive = false; + /** + * The recorded pauses of each shift (1..5) as wall-clock ranges, read-only (#1741). + * Empty for a shift with a pause override: the served stamps of such a shift are + * a synthesized pair carrying the override's duration, not times anyone recorded. + */ + pauseSegments: { [shift: number]: PauseSegment[] } = {}; date: any; // fejltekst til stop2 (bruges sammen med gruppevalidator) @@ -225,6 +232,12 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { const m = this.data.planningPrDayModels; + for (const shift of [1, 2, 3, 4, 5]) { + this.pauseSegments[shift] = this.shiftOverrideMinutes(shift) === null + ? buildPauseSegments(this.getPauseTimestampPairs(shift)) + : []; + } + const normalizeTwoDecimals = (value: any) => { if (value === null || value === undefined || isNaN(value)) {return 0.00;} const rounded = Number(value).toFixed(2); From 54d4ecf1ea474d6820f8cc27c79b4cb124a4c3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 3 Oct 2026 10:47:48 +0200 Subject: [PATCH 2/2] test(plannings): the recorded-pause cases clear the pause overrides other cases leave behind (#1741) The dialog spec shares one data object across the suite, and the Approach C "prefers the served override" case leaves pause1OverrideMinutes set. The recorded-pause block now clears every override and stamp it reads before each case as well as after, so shift 1 is not seen as overridden. buildPauseSegments now also accepts the Date stamps the core DateInterceptor hands the dialog (date-fns parseJSON, zone-less strings read as UTC), with a case covering that shape. Co-Authored-By: Claude Opus 5.5 --- .../plannings/pause-segments.util.spec.ts | 8 ++++++++ .../components/plannings/pause-segments.util.ts | 15 +++++++++++---- .../workday-entity-dialog.component.spec.ts | 15 ++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts index aceb3b8e..f00cc44d 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.spec.ts @@ -17,6 +17,14 @@ describe('buildPauseSegments', () => { ]); }); + it('reads Date stamps, which is how they reach the dialog, by their UTC fields', () => { + // The core DateInterceptor parses the API's zone-less wall-clock strings with + // date-fns parseJSON, i.e. as UTC. + expect(buildPauseSegments([ + [new Date(Date.UTC(2026, 4, 15, 10, 2)), new Date(Date.UTC(2026, 4, 15, 10, 17))], + ])).toEqual([{ start: '10:02', stop: '10:17' }]); + }); + it('merges overlapping and touching pauses, as the backend does before cutting them out', () => { expect(buildPauseSegments([ ['2026-05-15T10:00:00Z', '2026-05-15T10:20:00Z'], diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts index a6c8778b..ef94f18f 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/pause-segments.util.ts @@ -7,9 +7,16 @@ export interface PauseSegment { const SECONDS_PER_DAY = 86400; /** - * Seconds-of-day of a stamp. The stamps are wall-clock times serialised as UTC, the - * same reading the grid and the dialog give them through DatePipe(..., 'UTC'). + * A pause stamp as the dialog receives it. The API sends the stored wall-clock time + * without a zone, and the core DateInterceptor turns it into a Date with date-fns + * parseJSON, which reads a zone-less string as UTC. So the stamp's UTC fields ARE + * the wall-clock time, the same reading the grid gives it through + * DatePipe(..., 'UTC'). A string is accepted too, but only with an explicit zone + * (e.g. a trailing Z); a zone-less string would be read as local time. */ +export type PauseStamp = string | Date | null | undefined; + +/** Seconds-of-day of a stamp, read from its UTC fields (see PauseStamp). */ function secondOfDay(stamp: Date): number { return stamp.getUTCHours() * 3600 + stamp.getUTCMinutes() * 60 + stamp.getUTCSeconds(); } @@ -23,7 +30,7 @@ function toHhmm(seconds: number): string { return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; } -function parse(value: string | null | undefined): Date | null { +function parse(value: PauseStamp): Date | null { if (!value) { return null; } @@ -47,7 +54,7 @@ function parse(value: string | null | undefined): Date | null { * A stop with no start says nothing and is skipped. */ export function buildPauseSegments( - pairs: ReadonlyArray, + pairs: ReadonlyArray, ): PauseSegment[] { const closed: Array<{ start: number; stop: number }> = []; const open: number[] = []; 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 092a9b9b..92bf8c3b 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 @@ -533,16 +533,21 @@ describe('WorkdayEntityDialogComponent', () => { }); describe('Recorded pause times (#1741)', () => { - // mockData is shared by reference across the suite, so every stamp and override - // this block writes is put back afterwards. + // mockData is shared by reference across the suite, and other blocks leave pause + // overrides on it (an override hides a shift's list), so every field this block + // reads is cleared before each case and put back afterwards. const touched = ['pause1StartedAt', 'pause1StoppedAt', 'pause10StartedAt', 'pause10StoppedAt', - 'pause2StartedAt', 'pause2StoppedAt', 'pause2OverrideMinutes']; - afterEach(() => { + 'pause2StartedAt', 'pause2StoppedAt', + 'pause1OverrideMinutes', 'pause2OverrideMinutes', 'pause3OverrideMinutes', + 'pause4OverrideMinutes', 'pause5OverrideMinutes']; + const clearTouched = () => { const m = component.data.planningPrDayModels as any; for (const key of touched) { m[key] = null; } - }); + }; + beforeEach(clearTouched); + afterEach(clearTouched); it('lists each shift\'s pauses as clock ranges, sub-slots included', () => { const m = component.data.planningPrDayModels as any;