zenith branch worktree-T-1588+merged-report-periods commits 4 (vs origin/main) files 9 touched lines +484 / -48

Pre-push review: Merged Report Periods (T-1588)

Leave/Sick report periods now merge across non-working days into a single span showing absence days and total calendar days.

At a glance

  • Core behaviour: a leave/sick run merges across a gap only when every in-between day is a weekend or .nonWorkingDay; a worked day, different status, or unknown weekday splits it.
  • Display: 22 Dec 2025 – 12 Jan 2026 (13 leave days / 22 total), collapsing to a single count when nothing is bridged.
  • No signature change to groupConsecutivePeriods — it already received the full multi-status period set.
  • View and Markdown export are read-through (displayString); no logic change there.
  • Tests cover the canonical 13/22 case plus weekend/holiday bridges, mixed-gap split, trailing-weekend non-inflation, worked-day split, and sick parity.

Verdict

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.

Review findings

5 raised · 3 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What this does

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.

Why it matters

It matches how people think about time off ("I was away 22 Dec to 12 Jan") instead of stitching five rows together.

Key idea

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.

Changes overview

  • 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.

Approach

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.

Trade-offs

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.

Deep dive

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).

Architecture impact

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.

Edge cases / watch

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.

Important changes — detailed

groupConsecutivePeriods: non-working-day bridging predicate

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

Takeaway. A run-grouping method already passed the full multi-status set can change its grouping predicate with no signature/plumbing change — check the call site first.
Rationale. Merges only across weekends/.nonWorkingDay; a worked/different-status/unknown-weekday day splits the span (Decisions 1, 5).

DatePeriod.displayString + unitWord

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

Takeaway. Show the two-count form only when it differs (totalDayCount > dayCount) to avoid a redundant '3/3'.
Rationale. Labelled '(N leave days / T total)' is self-describing where the ticket's bare '22/13' is ambiguous (Decision 3).

inclusiveDayCount / makePeriod: total-day derivation

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

Takeaway. Span endpoints are always status days, so leading/trailing non-working days never inflate the total.
Rationale. Stored rather than recomputed in displayString to keep DatePeriod a plain value type (Decision 4).

Pre-push fixes: weekend check + invariant assertion

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

Takeaway. When you already hold a Date, check the weekend via calendar.isDateInWeekend rather than re-parsing a string.
Rationale. Efficiency review (should-fix) + quality review (polish), applied in cedfae9.

Key decisions

Non-working-only bridging

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).

Apply to both leave and sick

Identical problem and a shared code path; forking to keep old sick behaviour would add code for no benefit.

Keep dayCount, add totalDayCount + status

Minimal test churn; store rather than recompute; store the WorkStatus enum rather than a free-text label.

Avoid WorkStatus.isWorkingDay as the gap classifier

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.

Review findings

SeverityAreaFindingResolution
minorReportService.onlyNonWorkingBetweenPer 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.
minorDatePeriod.unitWorddefault 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.
minorCHANGELOG.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.
nitDatePeriod display copyLiteral "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.
nitReportView.statisticsUncached 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.

Per-file diffs

Click to expand.

zenith/Services/ReportService.swift Modified +100 / -33
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
zenith/Models/DatePeriod.swift Modified +49 / -9
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").
zenithTests/ReportServiceTests.swift Modified +202 / -0
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
zenithTests/DatePeriodTests.swift Added +127 / -0
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"))+    }+}
CHANGELOG.md Modified +6 / -6
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

Things to double-check

Run-time suite confirmed

make test-unitTest Succeeded and make build → pass, run locally on iPhone 17 Pro simulator after the review fixes.

Existing tests semantically intact

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.