Show six labeled activity months

This commit is contained in:
Georg Bauer
2026-07-31 10:52:40 +02:00
parent 201f32f125
commit 82fadda983
5 changed files with 97 additions and 15 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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<HeatCell>, 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<HeatCell>, 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()),

View File

@@ -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)
}
}

View File

@@ -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
),