Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4270,7 +4270,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,
Expand Down Expand Up @@ -4351,7 +4351,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,
Expand Down
74 changes: 73 additions & 1 deletion apps/swift-ios/Features/Workspace/DailyUXModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,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 {
Expand Down Expand Up @@ -868,7 +919,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)
Expand All @@ -880,6 +933,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
}
Expand Down
21 changes: 15 additions & 6 deletions apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,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,
Expand Down Expand Up @@ -455,9 +459,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)"
}
Expand Down Expand Up @@ -491,7 +496,7 @@ struct HomeThreadCollectionView: UIViewRepresentable {
threadID: String,
cell: HomeCollectionCell
) {
guard case let .thread(thread, context, _, _, _) = itemsByID[.thread(threadID)],
guard case let .thread(thread, context, style, _, _) = itemsByID[.thread(threadID)],
let indexPath = dataSource?.indexPath(for: .thread(threadID)),
collectionView?.cellForItem(at: indexPath) === cell else {
return
Expand All @@ -501,7 +506,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(
Expand Down
3 changes: 2 additions & 1 deletion apps/swift-ios/Features/Workspace/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,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)")
Expand Down
103 changes: 103 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading