From 8c4cf154a7fc2366b430071472340f766d475c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 2 May 2026 09:26:13 +0200 Subject: [PATCH] feat(web+excel): show seconds in actual-stamp displays when UseOneMinuteIntervals on (Phase 4) Phase 4 of the UseOneMinuteIntervals second-precision rollout. When a row's AssignedSite has the flag on: - Excel export's GetShiftTime returns HH:mm:ss strings sourced from Start1StartedAt etc. instead of the 5-min Options lookup - The web-admin plannings table displays actual-stamp cells via a new formatStamp(row, field) helper that picks 'HH:mm:ss' - The day-edit dialog's six time-pickers shift their minutesGap from 5 to 1 (minute-precision input; sub-minute input deferred per Q2(c)) - The padZero/displayTimeFormat angular helpers gain a sibling that includes :SS; the original helpers stay untouched. Behavior with the flag off is byte-identical to before. Excel export path falls through to the legacy 5-min Options[] lookup when actualStamp is null OR flag off. Plan: /home/rene/.claude/plans/parallel-twirling-balloon.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlanRegistrationHelperTests.cs | 97 ++++++++++++++++ .../Planning/TimePlanningPlanningModel.cs | 8 ++ .../TimePlanningPlanningService.cs | 6 + .../TimePlanningWorkingHoursService.cs | 58 +++++++--- .../b/dashboard-edit-multishift.spec.ts | 41 +++++++ .../workday-entity-dialog.component.html | 12 +- .../workday-entity-dialog.component.ts | 11 ++ .../time-plannings-table.component.html | 20 ++-- .../time-plannings-table.component.spec.ts | 107 ++++++++++++++++++ .../time-plannings-table.component.ts | 59 ++++++++++ .../models/plannings/time-planning.model.ts | 7 ++ 11 files changed, 392 insertions(+), 34 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs index 60688b681..0bba518b5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs @@ -780,4 +780,101 @@ public void Index_FlagOn_DerivedFieldsConsistent() // Assert.That(row.SumFlexEnd, Is.EqualTo(row.SumFlexEndInSeconds / 3600.0).Within(0.0001)); Assert.Pass("Captured for future fixture work; see XML doc above."); } + + // ------------------------------------------------------------------ + // Phase 4 — web admin display + Excel export: HH:mm:ss when flag on + // ------------------------------------------------------------------ + // + // The fork lives in a private helper on + // TimePlanningWorkingHoursService.GetShiftTime(plr, shift, actualStamp, useOneMinuteIntervals) + // which is called from FillDataRow when emitting Excel cells. The + // existing 2-arg overload (Options[] lookup) is left untouched; the new + // 4-arg overload formats `actualStamp` as `HH:mm:ss` when both + // `useOneMinuteIntervals` and `actualStamp.HasValue` are true, and falls + // through to the 2-arg overload otherwise. + // + // Because the helper is `private` and the test fixture has no + // `InternalsVisibleTo` attribute, the contract is captured here as the + // documented carve-out (mirroring the Phase 0/1/2 patterns above) so + // that the assertion intent survives any future refactor that exposes + // the helper for direct testing. + + /// + /// Phase 4 contract: when UseOneMinuteIntervals is on AND a + /// precise actualStamp is supplied, the new + /// GetShiftTime(plr, shift, actualStamp, useOneMinuteIntervals) + /// overload returns the stamp formatted as HH:mm:ss rather than + /// the legacy 5-minute plr.Options[shift - 1] lookup. + /// + [Test] + [Ignore("Phase 4 carve-out: GetShiftTime is private on TimePlanningWorkingHoursService and the fixture has no InternalsVisibleTo; assertion captured for future fixture work.")] + public void GetShiftTime_FlagOnWithActualStamp_ReturnsHHmmss() + { + // Arrange / Act / Assert (intent, to be enabled when the helper is + // exposed via InternalsVisibleTo or extracted to a testable seam): + // + // var plr = new PlanRegistration + // { + // Start1Id = 84, // 5-min idx 84 → "07:00" via Options[83] + // Start1StartedAt = new DateTime(2026, 5, 15, 7, 3, 53), + // }; + // var resultFlagOn = service.GetShiftTimeForTest(plr, plr.Start1Id, plr.Start1StartedAt, useOneMinuteIntervals: true); + // var resultFlagOff = service.GetShiftTimeForTest(plr, plr.Start1Id, plr.Start1StartedAt, useOneMinuteIntervals: false); + // + // Assert.That(resultFlagOn, Is.EqualTo("07:03:53")); + // Assert.That(resultFlagOff, Is.EqualTo("07:00")); // Options[83] = legacy snap + Assert.Pass("Captured for future fixture work; see XML doc above."); + } + + /// + /// Phase 4 contract: the Excel dashboard export + /// (GenerateExcelDashboard) emits HH:mm:ss string cells + /// for shift start/stop columns when the row's AssignedSite has + /// UseOneMinuteIntervals on, sourced from the precise + /// Start1StartedAt/Stop1StoppedAt stamps. With the flag + /// off, cell values must remain byte-identical to the legacy 5-minute + /// Options[] lookup. + /// + [Test] + [Ignore("Phase 4 carve-out: GenerateExcelDashboard requires the full Excel/SDK/DB fixture not wired here; assertion captured for future fixture work.")] + public void GenerateExcelDashboard_FlagOn_FormatsShiftCellsAsHHmmss() + { + // Arrange / Act / Assert (intent, to be enabled when fixture lands): + // + // var assignedSite = new AssignedSite { SiteId = 950, UseOneMinuteIntervals = true }; + // var planning = new PlanRegistration + // { + // SdkSitId = 950, + // Date = new DateTime(2026, 5, 15, 0, 0, 0), + // Start1Id = 84, + // Stop1Id = 187, + // Start1StartedAt = new DateTime(2026, 5, 15, 7, 3, 53), + // Stop1StoppedAt = new DateTime(2026, 5, 15, 15, 30, 11), + // PlanHours = 8.0, + // }; + // await assignedSite.Create(TimePlanningPnDbContext); + // await planning.Create(TimePlanningPnDbContext); + // + // var stream = await service.GenerateExcelDashboard(new TimePlanningWorkingHoursRequestModel + // { + // SiteId = 950, + // DateFrom = new DateTime(2026, 5, 15, 0, 0, 0), + // DateTo = new DateTime(2026, 5, 15, 0, 0, 0), + // }); + // + // using var pkg = new ExcelPackage(stream); + // var sheet = pkg.Workbook.Worksheets.First(); + // // Shift1Start column is the 8th cell on data row 2 (1-indexed). + // Assert.That(sheet.Cells[2, 8].Text, Is.EqualTo("07:03:53")); + // Assert.That(sheet.Cells[2, 9].Text, Is.EqualTo("15:30:11")); + // + // // Flip the flag back off → cells must show the legacy 5-min Options[] strings. + // assignedSite.UseOneMinuteIntervals = false; + // await assignedSite.Update(TimePlanningPnDbContext); + // var stream2 = await service.GenerateExcelDashboard(...); + // using var pkg2 = new ExcelPackage(stream2); + // Assert.That(pkg2.Workbook.Worksheets.First().Cells[2, 8].Text, Is.EqualTo("07:00")); + // Assert.That(pkg2.Workbook.Worksheets.First().Cells[2, 9].Text, Is.EqualTo("15:30")); + Assert.Pass("Captured for future fixture work; see XML doc above."); + } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs index ce9f7ad7d..add940d46 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs @@ -40,5 +40,13 @@ public class TimePlanningPlanningModel public string DeviceModel { get; set; } public string DeviceManufacturer { get; set; } public bool SoftwareVersionIsValid { get; set; } + /// + /// Phase 4: per-row mirror of AssignedSite.UseOneMinuteIntervals. + /// The web admin plannings table reads this to decide whether to display + /// actual stamps (start1StartedAt etc.) at HH:mm:ss rather + /// than the legacy HH:mm. Default false preserves byte- + /// identical behavior on rows whose site has the flag off. + /// + public bool UseOneMinuteIntervals { get; set; } public List PlanningPrDayModels { get; set; } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index f62ca406f..c1b7859c9 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -242,6 +242,9 @@ await dbContext.AssignedSites { SiteId = dbAssignedSite.SiteId, SiteName = site.Name, + // Phase 4: per-row mirror of the assigned-site flag drives the + // web admin's HH:mm vs HH:mm:ss display path in the plannings table. + UseOneMinuteIntervals = dbAssignedSite.UseOneMinuteIntervals, PlanningPrDayModels = new List() }; @@ -451,6 +454,9 @@ public async Task> IndexByCurrent { SiteId = (int)site.MicrotingUid!, SiteName = site.Name, + // Phase 4: per-row mirror of the assigned-site flag drives the + // web admin's HH:mm vs HH:mm:ss display path in the plannings table. + UseOneMinuteIntervals = dbAssignedSite.UseOneMinuteIntervals, PlanningPrDayModels = new List() }; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index c82d50126..dc03eac62 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -2532,7 +2532,7 @@ public async Task> GenerateExcelDashboard(TimePlanni foreach (var planning in timePlannings) { var dataRow = new Row() { RowIndex = (uint)rowIndex }; - FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled); + FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, assignedSite.UseOneMinuteIntervals); // Append pay code values for this day var dayPayLines = payLinesByDate.ContainsKey(planning.Date) @@ -2651,7 +2651,7 @@ public async Task> GenerateExcelDashboard(TimePlanni } private void FillDataRow(Row dataRow, Worker worker, Microting.eForm.Infrastructure.Data.Entities.Site site, CultureInfo culture, - TimePlanningWorkingHoursModel planning, PlanRegistration plr, Language language, bool isThirdShiftEnabled, bool isFourthShiftEnabled, bool isFifthShiftEnabled) + TimePlanningWorkingHoursModel planning, PlanRegistration plr, Language language, bool isThirdShiftEnabled, bool isFourthShiftEnabled, bool isFifthShiftEnabled, bool useOneMinuteIntervals = false) { try { dataRow.Append(CreateCell(worker.EmployeeNo ?? string.Empty)); @@ -2661,12 +2661,16 @@ private void FillDataRow(Row dataRow, Worker worker, Microting.eForm.Infrastruct dataRow.Append(CreateWeekNumberCell(planning.Date)); dataRow.Append(CreateCell(planning.PlanText)); dataRow.Append(CreateNumericCell(planning.PlanHours)); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift1Start))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift1Stop))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift1Pause))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift2Start))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift2Stop))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift2Pause))); + // Phase 4: when UseOneMinuteIntervals is on, format actual stamps (start/stop) + // from the precise DateTime stamps with second precision; pause columns have + // no single representative stamp in the legacy 5-min Options[] view, so they + // pass actualStamp=null and fall through to the existing 2-arg lookup. + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift1Start, planning.Start1StartedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift1Stop, planning.Stop1StoppedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift1Pause, null, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift2Start, planning.Start2StartedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift2Stop, planning.Stop2StoppedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift2Pause, null, useOneMinuteIntervals))); dataRow.Append(CreateNumericCell(planning.NettoHoursOverrideActive ? planning.NettoHoursOverride : planning.NettoHours)); dataRow.Append(CreateNumericCell(planning.FlexHours)); dataRow.Append(CreateNumericCell(planning.SumFlexEnd)); @@ -2678,21 +2682,21 @@ private void FillDataRow(Row dataRow, Worker worker, Microting.eForm.Infrastruct dataRow.Append(CreateCell(planning.CommentOffice?.Replace("
", "\n"))); if (isThirdShiftEnabled) { - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift3Start))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift3Stop))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift3Pause))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift3Start, planning.Start3StartedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift3Stop, planning.Stop3StoppedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift3Pause, null, useOneMinuteIntervals))); } if (isFourthShiftEnabled) { - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift4Start))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift4Stop))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift4Pause))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift4Start, planning.Start4StartedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift4Stop, planning.Stop4StoppedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift4Pause, null, useOneMinuteIntervals))); } if (isFifthShiftEnabled) { - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift5Start))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift5Stop))); - dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift5Pause))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift5Start, planning.Start5StartedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift5Stop, planning.Stop5StoppedAt, useOneMinuteIntervals))); + dataRow.Append(CreateCell(GetShiftTime(plr, planning.Shift5Pause, null, useOneMinuteIntervals))); } } catch (Exception ex) @@ -2753,6 +2757,24 @@ private string GetShiftTime(PlanRegistration plr, int? shift) return shift > 0 ? plr.Options[(int)shift - 1] : ""; } + /// + /// Phase 4 second-precision overload: when + /// is on AND a precise is available, format the + /// stamp as HH:mm:ss directly (sourcing the value from + /// PlanRegistration.Start1StartedAt / Stop1StoppedAt / etc. + /// instead of the legacy 5-minute plr.Options lookup). For every + /// other case (flag off OR no actual stamp) this delegates to the existing + /// 2-arg method so the byte-identical 5-minute path is preserved. + /// + private string GetShiftTime(PlanRegistration plr, int? shift, DateTime? actualStamp, bool useOneMinuteIntervals) + { + if (useOneMinuteIntervals && actualStamp.HasValue) + { + return actualStamp.Value.ToString("HH:mm:ss", CultureInfo.InvariantCulture); + } + return GetShiftTime(plr, shift); + } + private string GetMessageText(int? messageId, Language language) { if (messageId == null) return string.Empty; @@ -3155,7 +3177,7 @@ public async Task> GenerateExcelDashboard( var dataRow = new Row() { RowIndex = (uint)rowIndex }; try { - FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled); + FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, cache?.AssignedSite?.UseOneMinuteIntervals ?? false); // Append pay code values for this day var dayPayLines = (cache != null && cache.PayLinesByDate.ContainsKey(planning.Date)) diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts index 245ec1ee6..5ae0cbaa2 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -307,4 +307,45 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () await page.locator('#cancelButton').click(); }); + + /** + * Phase 4 — second-precision DISPLAY: when a row's site has + * `useOneMinuteIntervals = true` AND the planning has a precise + * `start1StartedAt` stamp (e.g. 07:03:53), the plannings-table cell must + * render the stamp at HH:mm:ss instead of the legacy HH:mm. + * + * Server-side seeding `AssignedSite.UseOneMinuteIntervals = true` plus + * a planning with `Start1StartedAt = 2026-05-15 07:03:53` requires DB + * fixture work the CI playwright shard doesn't yet wire up (the tests + * here log in as admin and rely on the default seed). Captured here as + * a TODO so the assertion shape survives any future fixture work; the + * Phase 4 jest unit test on `formatStamp(...)` covers the contract for + * the merge-blocking path. + */ + test.skip('plannings-table renders HH:mm:ss for actual stamp when site flag is on', async ({ page }) => { + // TODO(phase 4 fixture): seed AssignedSite.UseOneMinuteIntervals = true + // for the worker referenced by #cell3_0 AND a PlanRegistration row with + // Start1StartedAt = '2026-05-15T07:03:53Z' on a date that lands inside + // the dashboard's default visible range. + // + // Then the assertion shape is: + // + // await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + // await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + // await waitForSpinner(page); + // + // const cellId = '#cell3_0'; + // await page.locator(cellId).scrollIntoViewIfNeeded(); + // + // // The first-shift actual line is rendered with id firstShiftActual{rowIdx}_{colField}. + // const firstShiftActual = page.locator('[id^="firstShiftActual"]').first(); + // await expect(firstShiftActual).toContainText('07:03:53'); + // // Negative guard — the legacy 5-min path would render '07:00' / '07:05' instead. + // await expect(firstShiftActual).not.toContainText(/07:0[05]\s/); + // + // Until the fixture lands the unit test + // `formatStamp (Phase 4) — uses HH:mm:ss format when row.useOneMinuteIntervals is true` + // covers the format-helper contract (eform-client/src/app/plugins/modules/time-planning-pn/ + // components/plannings/time-plannings-table/time-plannings-table.component.spec.ts). + }); }); 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 bc3e5d42a..dbd8f38f8 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 @@ -47,7 +47,7 @@ #plannedStartPicker [format]="24" [defaultTime]="workdayForm.get('planned.shift' + shiftId + '.start')?.value" - [minutesGap]="5" + [minutesGap]="useOneMinuteIntervals ? 1 : 5" (closed)="calculatePlanHours()"> @@ -80,7 +80,7 @@ #plannedPausePicker [format]="24" [defaultTime]="workdayForm.get('planned.shift' + shiftId + '.break')?.value" - [minutesGap]="5" + [minutesGap]="useOneMinuteIntervals ? 1 : 5" (closed)="calculatePlanHours()"> @@ -108,7 +108,7 @@ #plannedStopPicker [format]="24" [defaultTime]="workdayForm.get('planned.shift' + shiftId + '.stop')?.value" - [minutesGap]="5" + [minutesGap]="useOneMinuteIntervals ? 1 : 5" (closed)="calculatePlanHours()"> @@ -176,7 +176,7 @@ @@ -213,7 +213,7 @@ @@ -245,7 +245,7 @@ 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 073df03f7..f6e5ebfc7 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 @@ -64,6 +64,17 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { // Reactive form workdayForm!: FormGroup; + /** + * Phase 4: pulls UseOneMinuteIntervals off the assigned-site model passed + * into the dialog. Drives the six ngx-material-timepicker `[minutesGap]` + * bindings — when on, the picker steps in 1-minute increments instead of + * 5-minute snap (sub-minute INPUT is deferred per Q2(c); only DISPLAY + * shows seconds today). + */ + get useOneMinuteIntervals(): boolean { + return this.data?.assignedSiteModel?.useOneMinuteIntervals ?? false; + } + // UI / beregningsfelter isInTheFuture = false; maxPause1Id = 0; 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 552fe9911..f6cf3d9d7 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 @@ -174,9 +174,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start1StartedAt, 'HH:mm', 'UTC') }} + login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start1StartedAt) }} - - {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start1StartedAt, row.planningPrDayModels[col.field]?.stop1StoppedAt) }} logout + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start1StartedAt, row.planningPrDayModels[col.field]?.stop1StoppedAt) }} logout - warning @@ -192,9 +192,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start2StartedAt, 'HH:mm', 'UTC') }} + login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start2StartedAt) }} - - {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start2StartedAt, row.planningPrDayModels[col.field]?.stop2StoppedAt) }} logout + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start2StartedAt, row.planningPrDayModels[col.field]?.stop2StoppedAt) }} logout - warning @@ -211,9 +211,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start3StartedAt, 'HH:mm', 'UTC') }} + login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start3StartedAt) }} - - {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start3StartedAt, row.planningPrDayModels[col.field]?.stop3StoppedAt) }} logout + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start3StartedAt, row.planningPrDayModels[col.field]?.stop3StoppedAt) }} logout - warning @@ -230,9 +230,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start4StartedAt, 'HH:mm', 'UTC') }} + login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start4StartedAt) }} - - {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start4StartedAt, row.planningPrDayModels[col.field]?.stop4StoppedAt) }} logout + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start4StartedAt, row.planningPrDayModels[col.field]?.stop4StoppedAt) }} logout - warning @@ -249,9 +249,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start5StartedAt, 'HH:mm', 'UTC') }} + login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start5StartedAt) }} - - {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start5StartedAt, row.planningPrDayModels[col.field]?.stop5StoppedAt) }} logout + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start5StartedAt, row.planningPrDayModels[col.field]?.stop5StoppedAt) }} logout - warning diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts index 63943dd73..b946aa858 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts @@ -374,4 +374,111 @@ describe('TimePlanningsTableComponent', () => { expect(result).toBe('10:30'); }); }); + + // ------------------------------------------------------------------ + // Phase 4 — second-precision display when UseOneMinuteIntervals is on + // ------------------------------------------------------------------ + describe('formatStamp (Phase 4)', () => { + it('returns empty string when value is falsy', () => { + expect(component.formatStamp({ useOneMinuteIntervals: true }, null)).toBe(''); + expect(component.formatStamp({ useOneMinuteIntervals: true }, undefined as any)).toBe(''); + expect(component.formatStamp({ useOneMinuteIntervals: true }, '')).toBe(''); + }); + + it("uses HH:mm format when row.useOneMinuteIntervals is false", () => { + const transformSpy = jest + .spyOn(component['datePipe'], 'transform') + .mockReturnValue('07:03'); + const result = component.formatStamp( + { useOneMinuteIntervals: false }, + '2026-05-15T07:03:53Z', + ); + expect(transformSpy).toHaveBeenCalledWith('2026-05-15T07:03:53Z', 'HH:mm', 'UTC'); + expect(result).toBe('07:03'); + }); + + it("uses HH:mm:ss format when row.useOneMinuteIntervals is true", () => { + const transformSpy = jest + .spyOn(component['datePipe'], 'transform') + .mockReturnValue('07:03:53'); + const result = component.formatStamp( + { useOneMinuteIntervals: true }, + '2026-05-15T07:03:53Z', + ); + expect(transformSpy).toHaveBeenCalledWith('2026-05-15T07:03:53Z', 'HH:mm:ss', 'UTC'); + expect(result).toBe('07:03:53'); + }); + + it("falls back to HH:mm when row is null/undefined (defensive)", () => { + const transformSpy = jest + .spyOn(component['datePipe'], 'transform') + .mockReturnValue('07:03'); + const result = component.formatStamp(null as any, '2026-05-15T07:03:53Z'); + expect(transformSpy).toHaveBeenCalledWith('2026-05-15T07:03:53Z', 'HH:mm', 'UTC'); + expect(result).toBe('07:03'); + }); + }); + + describe('getStopTimeDisplayWithSeconds (Phase 4)', () => { + it('returns empty string when either timestamp is falsy', () => { + expect( + component.getStopTimeDisplayWithSeconds({ useOneMinuteIntervals: true }, null, '2026-05-15T10:00:00Z'), + ).toBe(''); + expect( + component.getStopTimeDisplayWithSeconds({ useOneMinuteIntervals: true }, '2026-05-15T08:00:00Z', null), + ).toBe(''); + }); + + it('returns 24:00 when stop is on a later day regardless of flag', () => { + expect( + component.getStopTimeDisplayWithSeconds( + { useOneMinuteIntervals: true }, + '2026-05-15T23:00:00Z', + '2026-05-16T01:00:00Z', + ), + ).toBe('24:00'); + }); + + it("uses HH:mm format when flag off (legacy parity)", () => { + const transformSpy = jest + .spyOn(component['datePipe'], 'transform') + .mockReturnValue('15:30'); + const result = component.getStopTimeDisplayWithSeconds( + { useOneMinuteIntervals: false }, + '2026-05-15T07:00:00Z', + '2026-05-15T15:30:11Z', + ); + expect(transformSpy).toHaveBeenCalledWith('2026-05-15T15:30:11Z', 'HH:mm', 'UTC'); + expect(result).toBe('15:30'); + }); + + it("uses HH:mm:ss format when flag on", () => { + const transformSpy = jest + .spyOn(component['datePipe'], 'transform') + .mockReturnValue('15:30:11'); + const result = component.getStopTimeDisplayWithSeconds( + { useOneMinuteIntervals: true }, + '2026-05-15T07:00:00Z', + '2026-05-15T15:30:11Z', + ); + expect(transformSpy).toHaveBeenCalledWith('2026-05-15T15:30:11Z', 'HH:mm:ss', 'UTC'); + expect(result).toBe('15:30:11'); + }); + }); + + describe('convertHoursToTimeWithSeconds (Phase 4)', () => { + it('formats whole-hour values with seconds suffix', () => { + expect(component.convertHoursToTimeWithSeconds(8)).toBe('08:00:00'); + }); + + it('handles fractional hours that include sub-minute precision', () => { + // 7h 3m 53s → 7 + 3/60 + 53/3600 ≈ 7.06472222... + const sevenThreeFiftyThree = 7 + 3 / 60 + 53 / 3600; + expect(component.convertHoursToTimeWithSeconds(sevenThreeFiftyThree)).toBe('07:03:53'); + }); + + it('emits negative sign for negative values', () => { + expect(component.convertHoursToTimeWithSeconds(-1.5)).toBe('-1:30:00'); + }); + }); }); 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 6b6a82638..df7d582f8 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 @@ -290,6 +290,43 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView return this.datePipe.transform(stoppedAt, 'HH:mm', 'UTC') ?? ''; } + /** + * Phase 4 second-precision sibling of the inline `datePipe.transform(..., 'HH:mm', 'UTC')` + * calls used in the plannings table. When the row's site has + * useOneMinuteIntervals on, the actual stamp cells render at + * HH:mm:ss; otherwise falls through to the legacy HH:mm + * format so flag-off rows are byte-identical to before. + */ + formatStamp(row: any, value: string | null | undefined): string { + if (!value) { + return ''; + } + const fmt = row?.useOneMinuteIntervals ? 'HH:mm:ss' : 'HH:mm'; + return this.datePipe.transform(value, fmt, 'UTC') ?? ''; + } + + /** + * Phase 4 second-precision sibling of {@link getStopTimeDisplay}. Same + * cross-day "24:00" guard as the legacy method, but emits seconds in the + * normal case when the row's site has the flag on. + */ + getStopTimeDisplayWithSeconds(row: any, startedAt: string | null, stoppedAt: string | null): string { + if (!startedAt || !stoppedAt) { + return ''; + } + const startDate = new Date(startedAt); + const stopDate = new Date(stoppedAt); + if ( + startDate.getUTCFullYear() !== stopDate.getUTCFullYear() || + startDate.getUTCMonth() !== stopDate.getUTCMonth() || + startDate.getUTCDate() !== stopDate.getUTCDate() + ) { + return '24:00'; + } + const fmt = row?.useOneMinuteIntervals ? 'HH:mm:ss' : 'HH:mm'; + return this.datePipe.transform(stoppedAt, fmt, 'UTC') ?? ''; + } + onFirstColumnClick(row: any): void { // only do something if the selectAuthIsAdmin$ is true this.selectAuthIsAdmin$.subscribe(value => { @@ -411,6 +448,28 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView return `${this.padZero(hrs)}:${this.padZero(mins)}`; } + /** + * Phase 4 second-precision sibling of {@link convertHoursToTime}. Adds a + * :SS tail for callers that want seconds-level display when the + * row's site has UseOneMinuteIntervals on. The legacy 2-component + * helper is intentionally left untouched so flag-off display paths remain + * byte-identical. + */ + convertHoursToTimeWithSeconds(hours: number): string { + const isNegative = hours < 0; + if (hours < 0) { + hours = Math.abs(hours); + } + const totalSeconds = Math.round(hours * 3600); + const hrs = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + if (isNegative) { + return `-${hrs}:${this.padZero(mins)}:${this.padZero(secs)}`; + } + return `${this.padZero(hrs)}:${this.padZero(mins)}:${this.padZero(secs)}`; + } + convertHoursToTimeWithTranslations(hours: number): string { const totalMinutes = Math.floor(hours * 60) const hrs = Math.floor(totalMinutes / 60); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts index bf1e65a45..b99d0b57c 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts @@ -15,4 +15,11 @@ export class TimePlanningModel { deviceModel: string; deviceManufacturer: string; softwareVersionIsValid: boolean; + /** + * Phase 4: mirror of the row's assigned-site UseOneMinuteIntervals flag. + * When true, the plannings table renders actual stamps at HH:mm:ss instead + * of HH:mm. Default false preserves byte-identical legacy behavior for + * rows whose site has the flag off. + */ + useOneMinuteIntervals: boolean; }