diff --git a/AGENTS.md b/AGENTS.md index 6536343..aa959a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,12 @@ Use the `tea` command-line tool as the preferred way to list, inspect, create, and update Gitea issues. Run it from the repository root so it can use the configured remote, for example `tea issues` or `tea issues 1`. +Run issue-detail commands directly, for example `tea issues 7`. When invoking +`tea` through an automated shell runner, allocate a pseudo-terminal (PTY); +interactive terminal use needs no extra flag. The detail command's terminal +rendering is unreliable without a PTY and may panic inside Gitea SDK version +handling. + ## Required pre-commit gates Run every gate below from the repository root before committing. Every command diff --git a/TESTING.md b/TESTING.md index 61ea042..c50eb42 100644 --- a/TESTING.md +++ b/TESTING.md @@ -108,8 +108,8 @@ xcrun simctl launch booted de.rfc1437.gotcha ## Home -- [ ] Home shows the selected server name, contribution total, 12-month heatmap, - and recent activity. +- [ ] Home shows the selected server name, contribution total, six labeled + months with gaps between them, and recent activity. - [ ] Activity rows have the correct icon, repository, summary, and date. - [ ] Tap repository, issue, pull-request, and commit activity. Each opens the matching native tab and destination. diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 20de04f..5530109 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -149,6 +149,8 @@ pub struct HeatCell { pub week: u32, pub day: u32, pub level: u32, + pub timestamp: i64, + pub contributions: i64, } #[derive(Clone, uniffi::Record)] @@ -585,7 +587,7 @@ fn activity_detail(activity: &models::Activity) -> String { } fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { - const DAYS: i64 = 52 * 7; + const DAYS: i64 = 6 * 31; let latest = data .iter() .filter_map(|entry| entry.timestamp) @@ -613,6 +615,8 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { } else { ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32 }, + timestamp: (start + index as i64) * 86_400, + contributions: count, }) .collect(); (cells, contribution_count) @@ -767,8 +771,16 @@ mod tests { }, ]; let (cells, total) = heat_cells(&heatmap); - assert_eq!((cells.len(), total), (364, 5)); - assert_eq!((cells[362].level, cells[363].level), (1, 4)); + assert_eq!((cells.len(), total), (186, 5)); + assert_eq!( + ( + cells[184].level, + cells[184].timestamp, + cells[184].contributions, + cells[185].level, + ), + (1, 100 * 86_400, 1, 4) + ); let label = models::Label { name: Some("bug".into()), diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index bf60a4a..2be1da3 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -1479,13 +1479,17 @@ public struct HeatCell: Equatable, Hashable { public var week: UInt32 public var day: UInt32 public var level: UInt32 + public var timestamp: Int64 + public var contributions: Int64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(week: UInt32, day: UInt32, level: UInt32) { + public init(week: UInt32, day: UInt32, level: UInt32, timestamp: Int64, contributions: Int64) { self.week = week self.day = day self.level = level + self.timestamp = timestamp + self.contributions = contributions } @@ -1506,7 +1510,9 @@ public struct FfiConverterTypeHeatCell: FfiConverterRustBuffer { try HeatCell( week: FfiConverterUInt32.read(from: &buf), day: FfiConverterUInt32.read(from: &buf), - level: FfiConverterUInt32.read(from: &buf) + level: FfiConverterUInt32.read(from: &buf), + timestamp: FfiConverterInt64.read(from: &buf), + contributions: FfiConverterInt64.read(from: &buf) ) } @@ -1514,6 +1520,8 @@ public struct FfiConverterTypeHeatCell: FfiConverterRustBuffer { FfiConverterUInt32.write(value.week, into: &buf) FfiConverterUInt32.write(value.day, into: &buf) FfiConverterUInt32.write(value.level, into: &buf) + FfiConverterInt64.write(value.timestamp, into: &buf) + FfiConverterInt64.write(value.contributions, into: &buf) } } @@ -2855,4 +2863,4 @@ public func uniffiEnsureGotchaCoreInitialized() { } } -// swiftlint:enable all \ No newline at end of file +// swiftlint:enable all diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift index 93d8d10..beb915c 100644 --- a/ios/Sources/ListScreens.swift +++ b/ios/Sources/ListScreens.swift @@ -403,18 +403,32 @@ final class HomeViewController: RefreshingTableViewController { final class HeatmapView: UIView { private let cells: [HeatCell] + private let calendar = Calendar(identifier: .gregorian) init(page: HomePage) { - cells = page.heatCells + let latest = page.heatCells.last.map { + Date(timeIntervalSince1970: TimeInterval($0.timestamp)) + } ?? Date() + let latestMonth = Calendar(identifier: .gregorian).date( + from: Calendar(identifier: .gregorian).dateComponents([.year, .month], from: latest) + ) ?? latest + let firstMonth = Calendar(identifier: .gregorian).date( + byAdding: .month, + value: -5, + to: latestMonth + ) ?? latestMonth + cells = page.heatCells.filter { + Date(timeIntervalSince1970: TimeInterval($0.timestamp)) >= firstMonth + } super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 132)) backgroundColor = .systemBackground let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22)) - title.text = "Activity · last 12 months" + title.text = "Activity · last 6 months" title.font = .preferredFont(forTextStyle: .subheadline) title.textColor = .secondaryLabel addSubview(title) - let total = UILabel(frame: CGRect(x: 16, y: 104, width: 300, height: 18)) - total.text = "\(page.contributionCount) contributions" + let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18)) + total.text = "\(cells.reduce(0) { $0 + $1.contributions }) contributions" total.font = .preferredFont(forTextStyle: .caption1) total.textColor = .tertiaryLabel addSubview(total) @@ -424,9 +438,51 @@ final class HeatmapView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func draw(_ rect: CGRect) { - let width = max(4, min(6, (bounds.width - 32) / 55)) + guard + let first = cells.first, + let last = cells.last + else { return } + let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp)) + let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp)) + guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else { + return + } + let monthFormatter = DateFormatter() + monthFormatter.dateFormat = "MMM" + let months = cells.reduce(into: [Date]()) { result, cell in + let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp)) + let month = calendar.date( + from: calendar.dateComponents([.year, .month], from: date) + ) ?? date + if result.last != month { result.append(month) } + } + let weekCount = CGFloat( + (calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1 + ) + let monthGap: CGFloat = 4 + let width = max(4, min(6, (bounds.width - 32) / weekCount - 1)) let gap = width + 1 + let labelAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.preferredFont(forTextStyle: .caption2), + .foregroundColor: UIColor.secondaryLabel, + ] + for (index, month) in months.enumerated() { + let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0 + monthFormatter.string(from: month).draw( + at: CGPoint( + x: 16 + CGFloat(days / 7) * gap + CGFloat(index) * monthGap, + y: 34 + ), + withAttributes: labelAttributes + ) + } for cell in cells { + let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp)) + let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0 + let month = calendar.date( + from: calendar.dateComponents([.year, .month], from: date) + ) ?? date + let monthIndex = months.firstIndex(of: month) ?? 0 let colors: [UIColor] = [ .systemGray5, UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1), @@ -437,8 +493,8 @@ final class HeatmapView: UIView { colors[Int(min(cell.level, 4))].setFill() UIBezierPath( roundedRect: CGRect( - x: 16 + CGFloat(cell.week) * gap, - y: 43 + CGFloat(cell.day) * gap, + x: 16 + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap, + y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap, width: width, height: width ),