Leave/Sick report periods now merge across non-working days into a single span showing absence days and total calendar days.
.nonWorkingDay; a worked day, different status, or unknown weekday splits it.22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total), collapsing to a single count when nothing is bridged.groupConsecutivePeriods — it already received the full multi-status period set.displayString); no logic change there.Ready to push
No blocking issues across reuse, quality, efficiency, and spec-adherence review. Implementation matches the smolspec and all five decisions; make test-unit and make build pass. Three review findings (one efficiency should-fix, one quality polish, one changelog structure fix) were applied in commit cedfae9.
6a2ec58 T-1588: Add Merged Report Periods feature spec bb45fa8 T-1588: Merge report periods bridged by non-working days 68ae979 T-1588: changelog for merged report periods cedfae9 T-1588: apply pre-push review fixes The Report lists your leave. Before, one holiday that ran across a weekend or public holiday was chopped into several lines. Now it shows as one row: 22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total) — the first number is actual leave days, the second is the whole calendar stretch.
It matches how people think about time off ("I was away 22 Dec to 12 Jan") instead of stitching five rows together.
Two runs of leave are joined into one only when the gap between them is entirely non-working (weekends/holidays). If you actually worked a day in the gap — or there's a weekday with no entry — the stretch splits, because you weren't really away the whole time.
DatePeriod.swift: added totalDayCount + status; kept dayCount as the status-day count. displayString renders single-day, unbridged, and bridged forms, deriving the unit word from status, with singular/plural and the / N total clause only when totalDayCount > dayCount.ReportService.groupConsecutivePeriods (no signature change): builds a [dateString: WorkStatus] lookup and merges adjacent status days when onlyNonWorkingBetween confirms every day strictly between is non-working. New helpers onlyNonWorkingBetween, inclusiveDayCount, makePeriod; removed dead isConsecutive.Run-length grouping where the "same run?" predicate changed from strictly-consecutive to separated-only-by-non-working-days. The method already received the multi-status set, so no new plumbing was needed.
Kept dayCount (vs rename) for low test churn; stored totalDayCount (vs recompute) to keep DatePeriod a plain value type; applied to leave and sick via the shared path.
onlyNonWorkingBetween walks earlier+1 … <later (endpoints excluded); a day bridges iff calendar.isDateInWeekend(cursor) || lookup[day] == .nonWorkingDay. It deliberately avoids WorkStatus.isWorkingDay, which treats .sick as working / .leave as non-working and would mis-bridge a sick span (Decision 5). Adjacent days loop zero times → bridge, preserving plain consecutive grouping.
dayCount counts status days; totalDayCount = inclusiveDayCount(first,last) with both endpoints always status days, so leading/trailing non-working days can't inflate the total. Two-count form appears iff totalDayCount > dayCount (reachable only when dayCount ≥ 2).
Contained to one model + one service method; view/export read through displayString. DatePeriod now carries its WorkStatus, centralising the unit word and guarded by assertionFailure for non-leave/sick.
Date-parse failure splits rather than over-merges; lookup uses uniquingKeysWith for duplicate dates; DST-safe via same-calendar local-midnight dates. Pre-existing: ReportView.statistics is uncached — slightly costlier now, out of scope.
ReportService.swift
Why it matters. The core behaviour change — correctness of every merged span depends on this predicate and its loop bounds.
What to look at. ReportService.swift onlyNonWorkingBetween + merge loop
DatePeriod.swift
Why it matters. The user-visible string shipped on screen and in exports; the format invariant lives here.
What to look at. DatePeriod.swift:42-70
ReportService.swift
Why it matters. Produces the '/ N total' number; off-by-one and endpoint-inflation are the failure modes.
What to look at. ReportService.swift inclusiveDayCount + makePeriod
ReportService.swift
Why it matters. Removes a per-gap-day regex/DateFormatter round-trip and makes the leave/sick-only invariant explicit.
What to look at. ReportService.swift onlyNonWorkingBetween; DatePeriod.swift unitWord
A run extends across a gap only if every day is a weekend or .nonWorkingDay; an unknown weekday breaks it (we can't assume absence).
Identical problem and a shared code path; forking to keep old sick behaviour would add code for no benefit.
Minimal test churn; store rather than recompute; store the WorkStatus enum rather than a free-text label.
It treats .sick as working and .leave as non-working, which would mis-bridge spans; an explicit weekend-or-Non-Working predicate is correct for both.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | ReportService.onlyNonWorkingBetween | Per gap-day, DateString.isWeekend re-parses a date string that was just produced from the cursor Date (regex + DateFormatter round-trip). | Use calendar.isDateInWeekend(cursor) directly; keep the string only for the lookup key. Behaviour identical. |
| minor | DatePeriod.unitWord | default branch silently returns "leave" for any non-leave/non-sick status (latent, unreachable today). | Handle .leave explicitly and assertionFailure on any other status, making the invariant explicit. |
| minor | CHANGELOG.md [Unreleased] | Duplicate ### Added and ### Changed headings (partly pre-existing) with a missing blank line before ### Changed. | Consolidated the duplicate Added/Changed groups; the T-1588 entry was already correct. |
| nit | DatePeriod display copy | Literal "leave"/"sick"/"total"/"day(s)" strings inline. | Left as-is — user-facing copy local to one ~15-line property; WorkStatus.displayName is title-case and wrong for mid-sentence use; tests pin the exact output. |
| nit | ReportView.statistics | Uncached computed property read many times per body; this change slightly raises per-call cost. | Pre-existing, out of scope for this diff, and not user-perceptible for bounded data. Noted for future memoisation. |
Click to expand.
diff --git a/zenith/Services/ReportService.swift b/zenith/Services/ReportService.swiftindex 03f0c05..27119f5 100644--- a/zenith/Services/ReportService.swift+++ b/zenith/Services/ReportService.swift@@ -133,69 +133,136 @@ struct ReportService { // MARK: - Consecutive Period Grouping - /// Groups consecutive entries of the same status into periods.+ /// Groups runs of the same status into spans, bridging non-working days.+ ///+ /// Two adjacent status days join the same span when every calendar day strictly+ /// between them is non-working — a weekend or a `.nonWorkingDay` entry. Any other+ /// in-between day (a worked day, a different tracked status, or a weekday with no+ /// recorded entry) closes the span. Each span carries the count of actual status+ /// days (`dayCount`) and the inclusive calendar span first→last status day+ /// (`totalDayCount`). /// /// - Parameters:- /// - entries: All work entries to analyze.+ /// - entries: The period-bounded, multi-status entry set. /// - status: The status to group (e.g., .leave, .sick).- /// - Returns: An array of date periods with contiguous dates of the given status.+ /// - Returns: An array of date spans for the given status. func groupConsecutivePeriods(entries: [WorkEntry], status: WorkStatus) -> [DatePeriod] {- // Filter to only the requested status- let filtered = entries+ // Lookup of every recorded day's status, to classify in-between gap days.+ let lookup = Dictionary(+ entries.map { ($0.dateString, $0.status) },+ uniquingKeysWith: { first, _ in first }+ )++ // The target-status days, sorted ascending.+ let statusDays = entries .filter { $0.status == status }- .sorted { $0.dateString < $1.dateString }+ .map(\.dateString)+ .sorted() - guard !filtered.isEmpty else { return [] }+ guard !statusDays.isEmpty else { return [] } var periods: [DatePeriod] = []- var currentPeriodStart = filtered[0].dateString- var currentPeriodEnd = filtered[0].dateString+ var spanStart = statusDays[0]+ var spanEnd = statusDays[0] var dayCount = 1 - for i in 1..<filtered.count {- let previousDate = filtered[i - 1].dateString- let currentDate = filtered[i].dateString+ for i in 1..<statusDays.count {+ let previousDate = statusDays[i - 1]+ let currentDate = statusDays[i] - if isConsecutive(previousDate, currentDate) {- // Extend the current period- currentPeriodEnd = currentDate+ if onlyNonWorkingBetween(previousDate, currentDate, lookup: lookup) {+ // Extend the current span across the non-working bridge.+ spanEnd = currentDate dayCount += 1 } else {- // Save the current period and start a new one- periods.append(DatePeriod(- startDate: currentPeriodStart,- endDate: currentPeriodEnd,- dayCount: dayCount+ periods.append(makePeriod(+ start: spanStart,+ end: spanEnd,+ dayCount: dayCount,+ status: status ))- currentPeriodStart = currentDate- currentPeriodEnd = currentDate+ spanStart = currentDate+ spanEnd = currentDate dayCount = 1 } } - // Don't forget the last period- periods.append(DatePeriod(- startDate: currentPeriodStart,- endDate: currentPeriodEnd,- dayCount: dayCount+ // Don't forget the last span.+ periods.append(makePeriod(+ start: spanStart,+ end: spanEnd,+ dayCount: dayCount,+ status: status )) return periods } - /// Checks if two date strings represent consecutive calendar days.- private func isConsecutive(_ date1: String, _ date2: String) -> Bool {- guard let d1 = DateString.toDate(date1),- let d2 = DateString.toDate(date2) else {+ /// Builds a `DatePeriod`, deriving `totalDayCount` as the inclusive calendar span.+ private func makePeriod(+ start: String,+ end: String,+ dayCount: Int,+ status: WorkStatus+ ) -> DatePeriod {+ DatePeriod(+ startDate: start,+ endDate: end,+ dayCount: dayCount,+ totalDayCount: inclusiveDayCount(from: start, to: end),+ status: status+ )+ }++ /// Whether every calendar day strictly between two status days is non-working.+ ///+ /// A day is non-working when it is a weekend or recorded as `.nonWorkingDay`. A+ /// worked day, a day of a different status, or a weekday with no entry is working+ /// and breaks the bridge. Deliberately avoids `WorkStatus.isWorkingDay`, which+ /// mis-classifies `.sick`/`.leave` (see decision log).+ private func onlyNonWorkingBetween(+ _ earlier: String,+ _ later: String,+ lookup: [String: WorkStatus]+ ) -> Bool {+ guard let earlierDate = DateString.toDate(earlier),+ let laterDate = DateString.toDate(later) else { return false } let calendar = Calendar.current- guard let nextDay = calendar.date(byAdding: .day, value: 1, to: d1) else {+ guard var cursor = calendar.date(byAdding: .day, value: 1, to: earlierDate) else { return false } - return calendar.isDate(nextDay, inSameDayAs: d2)+ while cursor < laterDate {+ let dateString = DateString.fromDate(cursor)+ // `cursor` is already a Date, so check the weekend directly rather than+ // re-parsing the string via DateString.isWeekend. The string is still+ // needed as the lookup key.+ let isNonWorking = calendar.isDateInWeekend(cursor) || lookup[dateString] == .nonWorkingDay+ if !isNonWorking {+ return false+ }+ guard let nextDate = calendar.date(byAdding: .day, value: 1, to: cursor) else {+ return false+ }+ cursor = nextDate+ }++ return true+ }++ /// Counts calendar days inclusively from `start` to `end` (1 when they are equal).+ private func inclusiveDayCount(from start: String, to end: String) -> Int {+ guard let startDate = DateString.toDate(start),+ let endDate = DateString.toDate(end) else {+ return 1+ }++ let calendar = Calendar.current+ let days = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0+ return days + 1 } // MARK: - Markdown Export
diff --git a/zenith/Models/DatePeriod.swift b/zenith/Models/DatePeriod.swiftindex 469b504..2195e9a 100644--- a/zenith/Models/DatePeriod.swift+++ b/zenith/Models/DatePeriod.swift@@ -7,26 +7,66 @@ import Foundation -/// Represents a contiguous period of dates (e.g., consecutive leave days).+/// Represents a span of leave/sick days, possibly bridged across non-working days.+///+/// A span groups consecutive runs of the same status that are separated only by+/// non-working days (weekends or `.nonWorkingDay` entries). `dayCount` is the number+/// of actual status days; `totalDayCount` is the inclusive calendar span from the+/// first to the last status day. For an unbridged span these two are equal. struct DatePeriod: Equatable {- /// The first date of the period in ISO format (YYYY-MM-DD).+ /// The first status date of the span in ISO format (YYYY-MM-DD). let startDate: String - /// The last date of the period in ISO format (YYYY-MM-DD).+ /// The last status date of the span in ISO format (YYYY-MM-DD). let endDate: String - /// The number of days in the period.+ /// The number of actual status days in the span.+ ///+ /// For a bridged span this is less than `totalDayCount` (the non-working bridge+ /// days are not status days). let dayCount: Int - /// Human-readable display string for the period.+ /// The inclusive calendar-day span from the first to the last status day. ///- /// Single day: "14 Jul 2025 (1 day)"- /// Multi-day: "14 Jul – 18 Jul 2025 (5 days)"+ /// Equals `dayCount` when no non-working bridge exists.+ let totalDayCount: Int++ /// The work status this span represents (e.g. `.leave`, `.sick`).+ let status: WorkStatus++ /// Human-readable display string for the span.+ ///+ /// Single day: `5 Feb 2026 (1 leave day)`+ /// Multi-day, no bridge: `16 Mar – 18 Mar 2026 (3 leave days)`+ /// Bridged span: `22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total)` var displayString: String {+ let unit = unitWord+ let dayWord = dayCount == 1 ? "day" : "days"+ let counts: String+ if totalDayCount > dayCount {+ counts = "\(dayCount) \(unit) \(dayWord) / \(totalDayCount) total"+ } else {+ counts = "\(dayCount) \(unit) \(dayWord)"+ }+ if startDate == endDate {- return "\(formatDate(startDate)) (1 day)"+ return "\(formatDate(startDate)) (\(counts))"+ }+ return "\(formatDate(startDate)) – \(formatDate(endDate)) (\(counts))"+ }++ /// The unit word derived from the span's status (e.g. "leave", "sick").+ private var unitWord: String {+ switch status {+ case .sick:+ return "sick"+ case .leave:+ return "leave"+ default:+ // A span is only ever built for .leave or .sick (see groupConsecutivePeriods).+ assertionFailure("DatePeriod built with unsupported status \(status)")+ return "leave" }- return "\(formatDate(startDate)) – \(formatDate(endDate)) (\(dayCount) days)" } /// Formats a date string for display (e.g., "14 Jul 2025").
diff --git a/zenithTests/ReportServiceTests.swift b/zenithTests/ReportServiceTests.swiftindex b8a1e81..f5eae3b 100644--- a/zenithTests/ReportServiceTests.swift+++ b/zenithTests/ReportServiceTests.swift@@ -518,6 +518,185 @@ struct ReportServiceGroupConsecutivePeriodsTests { #expect(periods.isEmpty) }++ // MARK: - Merged Span Tests (T-1588)++ @Test func canonicalExampleMergesIntoOneBridgedSpan() throws {+ // 22 Dec 2025 (Mon) → 12 Jan 2026 (Mon): leave on every weekday except the+ // Dec 25/26 and Jan 1 holidays, which are .nonWorkingDay; weekends bridge too.+ // Expect one span: 13 leave days over 22 calendar days.+ let container = try createTestContainer()+ let context = container.mainContext++ let leaveDays = [+ "2025-12-22", "2025-12-23", "2025-12-24",+ "2025-12-29", "2025-12-30", "2025-12-31",+ "2026-01-02",+ "2026-01-05", "2026-01-06", "2026-01-07", "2026-01-08", "2026-01-09",+ "2026-01-12"+ ]+ for day in leaveDays {+ context.insert(WorkEntry(dateString: day, status: .leave))+ }+ // Public holidays recorded as non-working days (weekends need no entry).+ for holiday in ["2025-12-25", "2025-12-26", "2026-01-01"] {+ context.insert(WorkEntry(dateString: holiday, status: .nonWorkingDay))+ }+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 1)+ #expect(periods.first?.startDate == "2025-12-22")+ #expect(periods.first?.endDate == "2026-01-12")+ #expect(periods.first?.dayCount == 13)+ #expect(periods.first?.totalDayCount == 22)+ #expect(periods.first?.displayString == "22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total)")+ }++ @Test func weekendOnlyGapBridgesSpan() throws {+ // Fri leave, Sat/Sun weekend (no entry), Mon leave → one span of 2 over 4 days.+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-18", status: .leave)) // Friday+ context.insert(WorkEntry(dateString: "2025-07-21", status: .leave)) // Monday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 1)+ #expect(periods.first?.dayCount == 2)+ #expect(periods.first?.totalDayCount == 4)+ }++ @Test func nonWorkingHolidayGapBridgesSpan() throws {+ // Wed leave, Thu holiday (.nonWorkingDay), Fri leave → one span of 2 over 3 days.+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-16", status: .leave)) // Wednesday+ context.insert(WorkEntry(dateString: "2025-07-17", status: .nonWorkingDay)) // Thursday holiday+ context.insert(WorkEntry(dateString: "2025-07-18", status: .leave)) // Friday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 1)+ #expect(periods.first?.dayCount == 2)+ #expect(periods.first?.totalDayCount == 3)+ }++ @Test func gapMixingWeekendAndUnknownWeekdaySplitsSpan() throws {+ // Thu leave, then Fri (unknown weekday, no entry) + Sat/Sun weekend, then Mon+ // leave. The unknown Friday breaks the bridge → two separate single-day spans.+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-17", status: .leave)) // Thursday+ // 2025-07-18 (Friday) has no entry — unknown weekday.+ // 2025-07-19/20 weekend.+ context.insert(WorkEntry(dateString: "2025-07-21", status: .leave)) // Monday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 2)+ let sorted = periods.sorted { $0.startDate < $1.startDate }+ #expect(sorted[0].startDate == "2025-07-17")+ #expect(sorted[0].endDate == "2025-07-17")+ #expect(sorted[0].dayCount == 1)+ #expect(sorted[0].totalDayCount == 1)+ #expect(sorted[1].startDate == "2025-07-21")+ #expect(sorted[1].dayCount == 1)+ }++ @Test func trailingWeekendDoesNotInflateTotal() throws {+ // Two consecutive leave days followed by a weekend (no further leave). The+ // weekend after the last leave day must not extend the span.+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-17", status: .leave)) // Thursday+ context.insert(WorkEntry(dateString: "2025-07-18", status: .leave)) // Friday+ context.insert(WorkEntry(dateString: "2025-07-19", status: .nonWorkingDay)) // Saturday+ context.insert(WorkEntry(dateString: "2025-07-20", status: .nonWorkingDay)) // Sunday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 1)+ #expect(periods.first?.startDate == "2025-07-17")+ #expect(periods.first?.endDate == "2025-07-18")+ #expect(periods.first?.dayCount == 2)+ #expect(periods.first?.totalDayCount == 2) // weekend not absorbed+ }++ @Test func singleWorkingDayBetweenLeaveSplitsSpans() throws {+ // A worked (home) day between two leave days breaks them into two spans.+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-14", status: .leave)) // Monday+ context.insert(WorkEntry(dateString: "2025-07-15", status: .home)) // Tuesday worked+ context.insert(WorkEntry(dateString: "2025-07-16", status: .leave)) // Wednesday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 2)+ }++ @Test func sickPeriodsMergeAcrossNonWorkingDaysIdentically() throws {+ // Sick spans bridge weekends/holidays the same way leave spans do.+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-18", status: .sick)) // Friday+ // 2025-07-19/20 weekend (no entry).+ context.insert(WorkEntry(dateString: "2025-07-21", status: .sick)) // Monday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .sick)++ #expect(periods.count == 1)+ #expect(periods.first?.dayCount == 2)+ #expect(periods.first?.totalDayCount == 4)+ #expect(periods.first?.status == .sick)+ #expect(periods.first?.displayString == "18 Jul 2025 – 21 Jul 2025 (2 sick days / 4 total)")+ }++ @Test func differentStatusInGapBreaksSpan() throws {+ // A sick day between two leave days breaks the leave span (decision 5: a+ // different tracked status is not a non-working bridge).+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-14", status: .leave)) // Monday+ context.insert(WorkEntry(dateString: "2025-07-15", status: .sick)) // Tuesday sick+ context.insert(WorkEntry(dateString: "2025-07-16", status: .leave)) // Wednesday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let periods = service.groupConsecutivePeriods(entries: entries, status: .leave)++ #expect(periods.count == 2)+ } } // MARK: - ExportMarkdown Tests@@ -677,6 +856,29 @@ struct ReportServiceExportMarkdownTests { } } + @Test func exportRendersMergedLeaveSpanAsSingleLine() throws {+ // An interspersed leave block (leave around a weekend) should export as one+ // merged line carrying both counts. [T-1588]+ let container = try createTestContainer()+ let context = container.mainContext++ context.insert(WorkEntry(dateString: "2025-07-18", status: .leave)) // Friday+ // 2025-07-19/20 weekend bridges.+ context.insert(WorkEntry(dateString: "2025-07-21", status: .leave)) // Monday+ context.insert(WorkEntry(dateString: "2025-07-22", status: .leave)) // Tuesday+ try context.save()++ let service = ReportService()+ let entries = try context.fetch(FetchDescriptor<WorkEntry>())+ let period = ReportPeriod.financialYear(2026)+ let stats = service.calculateStatistics(entries: entries, period: period)+ let markdown = service.exportToMarkdown(stats, period: period)++ // One merged span: 3 leave days over 5 calendar days (Fri → Tue).+ #expect(stats.leavePeriods.count == 1)+ #expect(markdown.contains("- 18 Jul 2025 – 22 Jul 2025 (3 leave days / 5 total)"))+ }+ @Test func exportWithNoUnknownDaysOmitsIncompleteSection() throws { let container = try createTestContainer() let context = container.mainContext
diff --git a/zenithTests/DatePeriodTests.swift b/zenithTests/DatePeriodTests.swiftnew file mode 100644index 0000000..ab4daca--- /dev/null+++ b/zenithTests/DatePeriodTests.swift@@ -0,0 +1,127 @@+//+// DatePeriodTests.swift+// zenithTests+//+// Created by Arjen Schwarz on 21/06/2026.+//++import Testing+import Foundation+@testable import zenith++// MARK: - DatePeriod displayString Tests++struct DatePeriodDisplayStringTests {++ // MARK: - Leave++ @Test func leaveSingleDay() {+ let period = DatePeriod(+ startDate: "2026-02-05",+ endDate: "2026-02-05",+ dayCount: 1,+ totalDayCount: 1,+ status: .leave+ )++ #expect(period.displayString == "5 Feb 2026 (1 leave day)")+ }++ @Test func leaveMultiDayNoBridge() {+ let period = DatePeriod(+ startDate: "2026-03-16",+ endDate: "2026-03-18",+ dayCount: 3,+ totalDayCount: 3,+ status: .leave+ )++ #expect(period.displayString == "16 Mar 2026 – 18 Mar 2026 (3 leave days)")+ }++ @Test func leaveBridgedSpan() {+ let period = DatePeriod(+ startDate: "2025-12-22",+ endDate: "2026-01-12",+ dayCount: 13,+ totalDayCount: 22,+ status: .leave+ )++ #expect(period.displayString == "22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total)")+ }++ // MARK: - Sick++ @Test func sickSingleDay() {+ let period = DatePeriod(+ startDate: "2026-02-05",+ endDate: "2026-02-05",+ dayCount: 1,+ totalDayCount: 1,+ status: .sick+ )++ #expect(period.displayString == "5 Feb 2026 (1 sick day)")+ }++ @Test func sickMultiDayNoBridge() {+ let period = DatePeriod(+ startDate: "2026-03-16",+ endDate: "2026-03-18",+ dayCount: 3,+ totalDayCount: 3,+ status: .sick+ )++ #expect(period.displayString == "16 Mar 2026 – 18 Mar 2026 (3 sick days)")+ }++ @Test func sickBridgedSpan() {+ let period = DatePeriod(+ startDate: "2025-12-22",+ endDate: "2026-01-12",+ dayCount: 13,+ totalDayCount: 22,+ status: .sick+ )++ #expect(period.displayString == "22 Dec 2025 – 12 Jan 2026 (13 sick days / 22 total)")+ }++ // MARK: - Format invariants++ @Test func usesEnDashSeparator() {+ let period = DatePeriod(+ startDate: "2026-03-16",+ endDate: "2026-03-18",+ dayCount: 3,+ totalDayCount: 3,+ status: .leave+ )++ // En dash (U+2013), not a hyphen-minus.+ #expect(period.displayString.contains("\u{2013}"))+ #expect(!period.displayString.contains(" - "))+ }++ @Test func totalClauseOnlyWhenBridged() {+ let noBridge = DatePeriod(+ startDate: "2026-03-16",+ endDate: "2026-03-18",+ dayCount: 3,+ totalDayCount: 3,+ status: .leave+ )+ let bridged = DatePeriod(+ startDate: "2026-03-16",+ endDate: "2026-03-20",+ dayCount: 3,+ totalDayCount: 5,+ status: .leave+ )++ #expect(!noBridge.displayString.contains("total"))+ #expect(bridged.displayString.contains("/ 5 total"))+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d215549..8089e04 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -13,22 +13,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Merged Report Periods feature spec (T-1588) at `specs/merged-report-periods/` (smolspec, decision log, tasks). - Developer `Makefile` wrapping `xcodebuild` with `build`, `test`/`test-unit`, and on-device `install`/`run` targets (`make install` auto-detects the connected device and deploys), plus a `.gitignore` for build output. - Tappable days in the Report's "Incomplete Data" section — tapping a missing day dismisses the report and navigates the main view to that day (and its week), reusing the existing notification navigation path. - Regression test `doesNotRequestNotificationPermissionWhenNotificationsDisabled` to reproduce unnecessary notification permission prompts when notifications are disabled - Initial bugfix investigation report for T-264 at `specs/bugfixes/avoid-permission-prompt-when-notifications-disabled/report.md`-### Changed--- Completed all v2-usability feature implementation phases (Data, Domain, Presentation, Integration)--### Added- - Added regression test `backfillIncludesTargetDateWhenTargetIsWeekend` and investigation report for T-84 weekend backfill target-date behavior - v2-usability feature specification with requirements, design, and decision log - Date Picker Navigation: Quick jump to any date via inline calendar picker - Custom Date Range Reports: Generate reports for arbitrary date ranges - Configurable Hours Per Day: Set working hours (1-12) stored per entry +### Changed++- Report Leave and Sick periods now merge runs separated only by non-working days (weekends or days marked Non-Working) into a single span, showing the absence-day count and — when the span bridges non-working days — the total calendar days, e.g. `22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total)`. A worked day, a different status, or a weekday with no entry still splits the span. (T-1588)+- Completed all v2-usability feature implementation phases (Data, Domain, Presentation, Integration)+ ### Added (Presentation Layer) - `DateHeader` component with inline date picker
make test-unit → Test Succeeded and make build → pass, run locally on iPhone 17 Pro simulator after the review fixes.
nonConsecutiveDaysAppearAsSeparateGroups (gap day is a Wednesday with no entry → unknown weekday → still splits) and groupingIgnoresOtherStatuses (.home in gap → splits) still hold under the new predicate.