From 8e9fdf2ec77793b13a08fe051ca9d94b64ca77a1 Mon Sep 17 00:00:00 2001 From: ififi2017 <33808161+ififi2017@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:15:28 +0800 Subject: [PATCH] feat(ios): measure the Live Activity's progress over the stretch it is shown for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work countdown is published only for the last 5, 15 or 30 minutes of a shift. Measured against the whole day, its bar arrives about 97% full and crawls the last three points before clock-off: it looks broken, and it spends the card's only bar restating what the digits above it already say better. Both meters now span the activity's own life — from the moment it was scheduled to appear to the end it is counting to. The payload carries that instant as `displayStartAtMs`, because the extension has no idea what lead time the user picked. The segments are the same effective ones, only clipped, so lunch still does not count and the bar still holds still across it. Overtime needs no special case: the rules bundle already extends the last segment when it is added, so the window grows with it instead of sitting full for the extra time. The percentage in the expanded island moves with the bar, because a meter and a number side by side that disagree are worse than either alone. `projectedProgress` stays as it is for payloads with no window — focus legs, and activities written by an older build — and its habit of taking the shift's own progress as a floor is exactly why the window needed its own function: 96.9% as a floor pins a fifteen-minute bar full from the first frame. `AppTests/LiveActivityWindowTests` pins all of it: the clip, the lunch gap inside a window, overtime, the missing floor, and the fallbacks. Six tests, run serially, all passing. Co-Authored-By: Claude Opus 5 --- .../Models/LiveActivityAttributes.swift | 51 ++++++++ .../Native/Services/LiveActivityService.swift | 3 +- .../AppTests/LiveActivityWindowTests.swift | 112 ++++++++++++++++++ .../App/WidgetExtension/OffWorkWidgets.swift | 10 +- 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src-mobile/ios/App/AppTests/LiveActivityWindowTests.swift diff --git a/src-mobile/ios/App/App/Native/Models/LiveActivityAttributes.swift b/src-mobile/ios/App/App/Native/Models/LiveActivityAttributes.swift index 97210f6..e1d49cb 100644 --- a/src-mobile/ios/App/App/Native/Models/LiveActivityAttributes.swift +++ b/src-mobile/ios/App/App/Native/Models/LiveActivityAttributes.swift @@ -91,6 +91,13 @@ nonisolated struct OffWorkActivityAttributes: ActivityAttributes, Sendable { var addPomodoroEnabled = false var stopFocusLabel: String? = nil var scheduledSessionID: String? = nil + /// When this activity was scheduled to appear. The work countdown is + /// published only for the last 5, 15 or 30 minutes of a shift, so its + /// meters measure that stretch rather than the whole day — see + /// `activityWindowSegments`. `nil` on a focus payload, which is + /// already its own block, and on payloads written before the field + /// existed, which keep measuring the whole shift. + var displayStartAtMs: Int64? = nil /// End of the chain, which is what "this activity is finished" means /// once a focus block is followed by its break and the next block. @@ -110,6 +117,50 @@ nonisolated struct OffWorkActivityAttributes: ActivityAttributes, Sendable { return surface == "focus" } + /// The stretch this activity is actually on screen for. + /// + /// The work countdown is published only for the last 5, 15 or 30 + /// minutes of a shift, so a meter measured against the whole day + /// arrives 97% full and crawls the last three points. It looks broken, + /// and it spends the card's only bar saying something the digits above + /// it already said better. The meters therefore span the activity's + /// own life: from the moment it was scheduled to appear to the end it + /// is counting to. Overtime needs no special case — the rules bundle + /// already extends the last segment when it is added. + /// + /// Lunch still does not count, because these are the same effective + /// segments, only clipped. A payload with no window keeps the whole + /// shift, which is what focus legs and older payloads want. + var windowSegments: [Segment] { + guard let displayStartAtMs else { return segments } + let clipped = segments.compactMap { segment -> Segment? in + let start = max(segment.startAtMs, displayStartAtMs) + guard start < segment.endAtMs else { return nil } + return Segment(startAtMs: start, endAtMs: segment.endAtMs) + } + return clipped.isEmpty ? segments : clipped + } + + /// Elapsed effective time across `windowSegments`, as a percentage. + /// + /// Unlike `projectedProgress` this does not take the payload's own + /// `progress` as a floor: that number is the whole shift's, and inside + /// a fifteen-minute window it would pin the bar at full from the first + /// frame. Without a window the two agree, so the floor is kept there. + func windowProgress(atMs nowMs: Int64) -> Double { + guard displayStartAtMs != nil else { return projectedProgress(atMs: nowMs) } + let window = windowSegments + let duration = window.reduce(Int64(0)) { $0 + max(0, $1.endAtMs - $1.startAtMs) } + guard duration > 0 else { return min(100, max(0, progress)) } + let elapsed = window.reduce(Int64(0)) { total, segment in + total + min( + max(0, segment.endAtMs - segment.startAtMs), + max(0, nowMs - segment.startAtMs) + ) + } + return min(100, max(0, Double(elapsed) / Double(duration) * 100)) + } + func projectedProgress(atMs nowMs: Int64) -> Double { let duration = segments.reduce(Int64(0)) { total, segment in total + max(0, segment.endAtMs - segment.startAtMs) diff --git a/src-mobile/ios/App/App/Native/Services/LiveActivityService.swift b/src-mobile/ios/App/App/Native/Services/LiveActivityService.swift index 013e7ff..d456218 100644 --- a/src-mobile/ios/App/App/Native/Services/LiveActivityService.swift +++ b/src-mobile/ios/App/App/Native/Services/LiveActivityService.swift @@ -505,7 +505,8 @@ final class LiveActivityService { completedNote: store.t("offWorkWellDone"), surface: LiveActivitySurface.work.rawValue, timerLabel: nil, - destination: "offworkcountdown://timer" + destination: "offworkcountdown://timer", + displayStartAtMs: Int64(scheduledStart.timeIntervalSince1970 * 1_000) ) let content = ActivityContent( state: state, diff --git a/src-mobile/ios/App/AppTests/LiveActivityWindowTests.swift b/src-mobile/ios/App/AppTests/LiveActivityWindowTests.swift new file mode 100644 index 0000000..16e2918 --- /dev/null +++ b/src-mobile/ios/App/AppTests/LiveActivityWindowTests.swift @@ -0,0 +1,112 @@ +import Testing +@testable import App + +/// The work countdown is only published for the last 5, 15 or 30 minutes of a +/// shift, so its bar and ring measure that window rather than the whole day. +/// These pin the arithmetic the card draws. +struct LiveActivityWindowTests { + private static let minute: Int64 = 60_000 + + /// 09:00–17:00 with an hour of lunch at noon, as absolute milliseconds. + private static func shift( + displayStartAtMs: Int64?, + progress: Double = 0 + ) -> OffWorkActivityAttributes.ContentState { + OffWorkActivityAttributes.ContentState( + endAtMs: 17 * 60 * minute, + progress: progress, + segments: [ + .init(startAtMs: 9 * 60 * minute, endAtMs: 12 * 60 * minute), + .init(startAtMs: 13 * 60 * minute, endAtMs: 17 * 60 * minute), + ], + phase: "working", + locale: "en", + appTitle: "DoneAt", + caption: "left today", + completedCaption: "Off work", + completedNote: "Well done", + displayStartAtMs: displayStartAtMs + ) + } + + @Test func aPayloadWithoutAWindowStillMeasuresTheWholeShift() { + let state = Self.shift(displayStartAtMs: nil) + + #expect(state.windowSegments == state.segments) + // Noon is three of the seven effective hours. + let atNoon = state.windowProgress(atMs: 12 * 60 * Self.minute) + #expect(abs(atNoon - 3.0 / 7.0 * 100) < 0.001) + } + + @Test func theWindowKeepsOnlyTheLastStretchOfTheShift() { + let state = Self.shift(displayStartAtMs: (16 * 60 + 45) * Self.minute) + + #expect(state.windowSegments == [ + .init(startAtMs: (16 * 60 + 45) * Self.minute, endAtMs: 17 * 60 * Self.minute), + ]) + #expect(state.windowProgress(atMs: (16 * 60 + 45) * Self.minute) == 0) + #expect(abs(state.windowProgress(atMs: (16 * 60 + 50) * Self.minute) - 100.0 / 3) < 0.001) + #expect(state.windowProgress(atMs: 17 * 60 * Self.minute) == 100) + } + + /// The shift's own progress must not become the window's floor. Before + /// this was split from `projectedProgress`, a payload carrying 96.9% — + /// which is what the last fifteen minutes of an eight-hour day looks like + /// — pinned the bar full from the activity's first frame. + @Test func theShiftsOwnProgressIsNotAFloorForTheWindow() { + let state = Self.shift(displayStartAtMs: (16 * 60 + 45) * Self.minute, progress: 96.9) + + #expect(state.windowProgress(atMs: (16 * 60 + 45) * Self.minute) == 0) + #expect(state.projectedProgress(atMs: (16 * 60 + 45) * Self.minute) == 96.9) + } + + /// A window that opens before lunch ends keeps both stretches, and the gap + /// between them stays uncounted: the bar holds still over lunch. + @Test func lunchInsideTheWindowIsStillNotWorkedTime() { + let state = Self.shift(displayStartAtMs: (11 * 60 + 30) * Self.minute) + + #expect(state.windowSegments == [ + .init(startAtMs: (11 * 60 + 30) * Self.minute, endAtMs: 12 * 60 * Self.minute), + .init(startAtMs: 13 * 60 * Self.minute, endAtMs: 17 * 60 * Self.minute), + ]) + // Half an hour of the four and a half in the window. + let atLunchStart = state.windowProgress(atMs: 12 * 60 * Self.minute) + #expect(abs(atLunchStart - 0.5 / 4.5 * 100) < 0.001) + #expect(state.windowProgress(atMs: (12 * 60 + 45) * Self.minute) == atLunchStart) + #expect(state.windowProgress(atMs: 13 * 60 * Self.minute) == atLunchStart) + } + + /// Overtime arrives as a longer last segment from the rules bundle, so the + /// window grows with it rather than sitting full for the extra time. + @Test func overtimeExtendsTheWindowRatherThanPinningItFull() { + var state = Self.shift(displayStartAtMs: (16 * 60 + 45) * Self.minute) + #expect(state.windowProgress(atMs: 17 * 60 * Self.minute) == 100) + + state = OffWorkActivityAttributes.ContentState( + endAtMs: (17 * 60 + 30) * Self.minute, + progress: state.progress, + segments: [ + state.segments[0], + .init(startAtMs: 13 * 60 * Self.minute, endAtMs: (17 * 60 + 30) * Self.minute), + ], + phase: "overtime", + locale: state.locale, + appTitle: state.appTitle, + caption: state.caption, + completedCaption: state.completedCaption, + completedNote: state.completedNote, + displayStartAtMs: state.displayStartAtMs + ) + + // Fifteen minutes of the forty-five the activity now covers. + #expect(abs(state.windowProgress(atMs: 17 * 60 * Self.minute) - 100.0 / 3) < 0.001) + #expect(state.windowProgress(atMs: (17 * 60 + 30) * Self.minute) == 100) + } + + /// A focus payload has no window: its single segment is already the block. + @Test func aWindowStartingPastEverySegmentFallsBackToTheShift() { + let state = Self.shift(displayStartAtMs: 23 * 60 * Self.minute) + + #expect(state.windowSegments == state.segments) + } +} diff --git a/src-mobile/ios/App/WidgetExtension/OffWorkWidgets.swift b/src-mobile/ios/App/WidgetExtension/OffWorkWidgets.swift index 8839d34..9f6364d 100644 --- a/src-mobile/ios/App/WidgetExtension/OffWorkWidgets.swift +++ b/src-mobile/ios/App/WidgetExtension/OffWorkWidgets.swift @@ -378,8 +378,8 @@ private struct ActivityMinimalRing: View { segments: [.init(startAtMs: span.start, endAtMs: span.end)], tint: tint ) - } else if !context.state.segments.isEmpty { - ActivitySegmentedRing(segments: context.state.segments, tint: tint) + } else if !context.state.windowSegments.isEmpty { + ActivitySegmentedRing(segments: context.state.windowSegments, tint: tint) } } } @@ -823,7 +823,7 @@ private func activityProgressValue(_ context: ActivityViewContext