Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src-mobile/ios/App/App/Native/Models/LiveActivityAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
112 changes: 112 additions & 0 deletions src-mobile/ios/App/AppTests/LiveActivityWindowTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 5 additions & 5 deletions src-mobile/ios/App/WidgetExtension/OffWorkWidgets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -823,7 +823,7 @@ private func activityProgressValue(_ context: ActivityViewContext<OffWorkActivit
let total = max(1, span.end - span.start)
return min(100, max(0, Double(nowMs - span.start) / Double(total) * 100))
}
return context.state.projectedProgress(
return context.state.windowProgress(
atMs: Int64(date.timeIntervalSince1970 * 1_000)
)
}
Expand All @@ -849,8 +849,8 @@ private func activityProgress(_ context: ActivityViewContext<OffWorkActivityAttr
.tint(activityTint(context, at: date))
.labelsHidden()
.frame(height: 6)
} else if !activityComplete(context, at: date), !context.state.segments.isEmpty {
activitySegmentedProgress(context.state.segments)
} else if !activityComplete(context, at: date), !context.state.windowSegments.isEmpty {
activitySegmentedProgress(context.state.windowSegments)
} else {
activityProgress(activityProgressValue(context, at: date))
}
Expand Down
Loading