diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 12356d540381..b43b52498448 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -4765,7 +4765,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, backgroundWorkIsActive: backgroundWorkIsActive, fallbackUpdatedAt: thread.updatedAt ), - latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), + latestTurnCompletedAt: thread.latestTurn?.completedAt.flatMap(parseValidDate), settlementFacts: settlementFacts( override: thread.settledOverride, session: thread.session, @@ -4846,7 +4846,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, backgroundWorkIsActive: backgroundWorkIsActive, fallbackUpdatedAt: thread.updatedAt ), - latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), + latestTurnCompletedAt: thread.latestTurn?.completedAt.flatMap(parseValidDate), settlementFacts: settlementFacts( override: thread.settledOverride, session: thread.session, diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index f74395a848ed..d3d970c3cbaf 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -802,6 +802,57 @@ enum HomeWorkingDuration { } } +/// The completion age shown in a completed rich Home row. +/// +/// Recent completions stay minute-granular because Home refreshes quiet rows every 60 seconds. +enum HomeDoneDuration { + static func compact(since date: Date, now: Date) -> String { + let minutes = elapsedMinutes(since: date, now: now) + guard minutes >= 1 else { return "now" } + guard minutes >= 60 else { return "\(minutes)m" } + let hours = minutes / 60 + guard hours >= 24 else { return "\(hours)h \(minutes % 60)m" } + let days = hours / 24 + guard days >= 7 else { return "\(days)d \(hours % 24)h" } + guard days >= 365 else { return "\(days / 7)w" } + return "\(days / 365)y" + } + + static func accessibility(since date: Date, now: Date) -> String { + "Completed \(elapsedPhrase(since: date, now: now))" + } + + private static func elapsedPhrase(since date: Date, now: Date) -> String { + let minutes = elapsedMinutes(since: date, now: now) + guard minutes >= 1 else { return "just now" } + guard minutes >= 60 else { return "\(unit(minutes, singular: "minute")) ago" } + + let hours = minutes / 60 + guard hours >= 24 else { + let remainingMinutes = minutes % 60 + guard remainingMinutes > 0 else { return "\(unit(hours, singular: "hour")) ago" } + return "\(unit(hours, singular: "hour")), \(unit(remainingMinutes, singular: "minute")) ago" + } + + let days = hours / 24 + guard days < 7 else { + guard days >= 365 else { return "\(unit(days / 7, singular: "week")) ago" } + return "\(unit(days / 365, singular: "year")) ago" + } + let remainingHours = hours % 24 + guard remainingHours > 0 else { return "\(unit(days, singular: "day")) ago" } + return "\(unit(days, singular: "day")), \(unit(remainingHours, singular: "hour")) ago" + } + + private static func elapsedMinutes(since date: Date, now: Date) -> Int { + max(0, Int(now.timeIntervalSince(date))) / 60 + } + + private static func unit(_ value: Int, singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } +} + extension FeatureThread { var homeStatus: HomeThreadStatus { switch state { @@ -858,7 +909,9 @@ extension FeatureThread { func homeRowStatusLabel(at now: Date) -> String { switch homeStatus { - case .done, .ready: + case .done: + homeDoneDuration(at: now) ?? SidebarRelativeAge.compact(since: updatedAt, now: now) + case .ready: SidebarRelativeAge.compact(since: updatedAt, now: now) case .approval, .input, .working, .monitoring, .failed: homeStatusLabel ?? SidebarRelativeAge.compact(since: updatedAt, now: now) @@ -870,6 +923,25 @@ extension FeatureThread { return HomeWorkingDuration.compact(since: workingStartedAt, now: now) } + func homeDoneDuration(at now: Date) -> String? { + guard homeStatus == .done, let latestTurnCompletedAt else { return nil } + return HomeDoneDuration.compact(since: latestTurnCompletedAt, now: now) + } + + func homeDoneAccessibilityLabel(at now: Date) -> String? { + guard homeStatus == .done, let latestTurnCompletedAt else { return nil } + return HomeDoneDuration.accessibility(since: latestTurnCompletedAt, now: now) + } + + func homeRowAccessibilityStatus(rich: Bool, at now: Date) -> String { + guard rich else { return homeStatusLabel ?? "Ready" } + if let completed = homeDoneAccessibilityLabel(at: now) { return completed } + if homeStatus == .done { + return "Done. \(SidebarRelativeAge.accessibility(since: updatedAt, now: now))" + } + return homeStatusLabel ?? "Ready" + } + var hasLiveWorkingDuration: Bool { homeStatus == .working && workingStartedAt != nil } diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index a492ef156019..55e52a3ed282 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -429,13 +429,17 @@ struct HomeThreadCollectionView: UIViewRepresentable { private func configureAccessibility(_ cell: HomeCollectionCell, item: HomeCollectionItem) { cell.accessibilityCustomActions = nil switch item { - case let .thread(thread, context, _, isArchived, _, _): + case let .thread(thread, context, style, isArchived, _, _): cell.isAccessibilityElement = true cell.accessibilityTraits = selectedThreadID == thread.id ? [.button, .selected] : .button cell.accessibilityLabel = thread.title - cell.accessibilityValue = threadAccessibilityValue(thread, context: context) + cell.accessibilityValue = threadAccessibilityValue( + thread, + context: context, + style: style + ) cell.accessibilityHint = "Opens thread. More actions are available." cell.accessibilityCustomActions = threadAccessibilityActions( for: thread, @@ -491,9 +495,10 @@ struct HomeThreadCollectionView: UIViewRepresentable { private func threadAccessibilityValue( _ thread: FeatureThread, - context: HomeThreadRowContext + context: HomeThreadRowContext, + style: FeatureThreadRow.Style ) -> String { - var status = thread.homeStatusLabel ?? "Ready" + var status = thread.homeRowAccessibilityStatus(rich: style == .rich, at: .now) if let duration = thread.homeWorkingDuration(at: .now) { status += " for \(duration)" } @@ -524,7 +529,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { cell: HomeCollectionCell ) { guard let identifier = threadItemIDs[threadID], - case let .thread(thread, context, _, _, _, _) = itemsByID[identifier], + case let .thread(thread, context, style, _, _, _) = itemsByID[identifier], let indexPath = dataSource?.indexPath(for: identifier), collectionView?.cellForItem(at: indexPath) === cell else { return @@ -534,7 +539,11 @@ struct HomeThreadCollectionView: UIViewRepresentable { } else { pullRequestsByThreadID.removeValue(forKey: threadID) } - cell.accessibilityValue = threadAccessibilityValue(thread, context: context) + cell.accessibilityValue = threadAccessibilityValue( + thread, + context: context, + style: style + ) } private func threadAccessibilityActions( diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index d23a22beb921..ed75626e64ab 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -1319,7 +1319,8 @@ struct FeatureThreadRow: View { } private func accessibilityValue(at now: Date) -> String { - var values = [thread.homeStatusLabel ?? "Ready", "Project \(context.projectName)"] + let status = thread.homeRowAccessibilityStatus(rich: style == .rich, at: now) + var values = [status, "Project \(context.projectName)"] values.append("Harness \(context.providerName)") if let duration = thread.homeWorkingDuration(at: now) { values.append("for \(duration)") diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index 8901461c4f95..0742e06e6eee 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -140,6 +140,109 @@ struct HomeThreadMetadataTests { #expect(idle.homeStatusAccessibilityLabel(at: now) == "Ready") } + @Test + func completedRowsShowABareAgeMeasuredFromCompletion() { + let thread = FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + updatedAt: now.addingTimeInterval(-60), + state: .completed, + latestTurnCompletedAt: now.addingTimeInterval(-9_360) + ) + + #expect(thread.homeDoneDuration(at: now) == "2h 36m") + #expect(thread.homeRowStatusLabel(at: now) == "2h 36m") + #expect( + thread.homeRowAccessibilityStatus(rich: true, at: now) + == "Completed 2 hours, 36 minutes ago" + ) + #expect(thread.homeRowAccessibilityStatus(rich: false, at: now) == "Done") + } + + @Test + func doneDurationsAreMinuteGranularAndClampFutureCompletions() { + #expect(doneDuration(completedAtOffset: 30) == "now") + #expect(doneDuration(completedAtOffset: -30) == "now") + #expect(doneDuration(completedAtOffset: -59) == "now") + #expect(doneDuration(completedAtOffset: -60) == "1m") + #expect(doneDuration(completedAtOffset: -3_600) == "1h 0m") + #expect(doneDuration(completedAtOffset: -5_465) == "1h 31m") + #expect(doneDuration(completedAtOffset: -86_400) == "1d 0h") + #expect(doneDuration(completedAtOffset: -273_600) == "3d 4h") + #expect(doneDuration(completedAtOffset: -604_800) == "1w") + #expect(doneDuration(completedAtOffset: -31_449_600) == "52w") + #expect(doneDuration(completedAtOffset: -31_536_000) == "1y") + #expect(doneDuration(completedAtOffset: -63_072_000) == "2y") + } + + @Test + func doneAccessibilityLabelsSpeakTheAgeInWords() { + #expect(doneAccessibilityLabel(completedAtOffset: -30) == "Completed just now") + #expect(doneAccessibilityLabel(completedAtOffset: -60) == "Completed 1 minute ago") + #expect(doneAccessibilityLabel(completedAtOffset: -120) == "Completed 2 minutes ago") + #expect(doneAccessibilityLabel(completedAtOffset: -3_600) == "Completed 1 hour ago") + #expect( + doneAccessibilityLabel(completedAtOffset: -9_360) == "Completed 2 hours, 36 minutes ago" + ) + #expect(doneAccessibilityLabel(completedAtOffset: -86_400) == "Completed 1 day ago") + #expect( + doneAccessibilityLabel(completedAtOffset: -273_600) == "Completed 3 days, 4 hours ago" + ) + #expect(doneAccessibilityLabel(completedAtOffset: -604_800) == "Completed 1 week ago") + #expect(doneAccessibilityLabel(completedAtOffset: -31_449_600) == "Completed 52 weeks ago") + #expect(doneAccessibilityLabel(completedAtOffset: -31_536_000) == "Completed 1 year ago") + #expect(doneAccessibilityLabel(completedAtOffset: -63_072_000) == "Completed 2 years ago") + } + + @Test + func onlyCompletedThreadsWithACompletionTimeShowADoneDuration() { + let completedWithoutTime = FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + updatedAt: now.addingTimeInterval(-120), + state: .completed + ) + let working = FeatureThread( + id: "working", + projectID: "project", + title: "Working task", + state: .working, + latestTurnCompletedAt: now.addingTimeInterval(-300) + ) + + #expect(completedWithoutTime.homeDoneDuration(at: now) == nil) + #expect(completedWithoutTime.homeDoneAccessibilityLabel(at: now) == nil) + #expect(completedWithoutTime.homeRowStatusLabel(at: now) == "2m") + #expect( + completedWithoutTime.homeRowAccessibilityStatus(rich: true, at: now) + == "Done. Updated 2 minutes ago" + ) + #expect(completedWithoutTime.homeRowAccessibilityStatus(rich: false, at: now) == "Done") + #expect(working.homeDoneDuration(at: now) == nil) + #expect(working.homeRowStatusLabel(at: now) == "Working") + } + + private func doneDuration(completedAtOffset: TimeInterval) -> String? { + completedThread(completedAtOffset: completedAtOffset).homeDoneDuration(at: now) + } + + private func doneAccessibilityLabel(completedAtOffset: TimeInterval) -> String? { + completedThread(completedAtOffset: completedAtOffset) + .homeDoneAccessibilityLabel(at: now) + } + + private func completedThread(completedAtOffset: TimeInterval) -> FeatureThread { + FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + state: .completed, + latestTurnCompletedAt: now.addingTimeInterval(completedAtOffset) + ) + } + private func accessibilityDuration(startedAtOffset: TimeInterval) -> String { HomeWorkingDuration.accessibility( since: now.addingTimeInterval(startedAtOffset),