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 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`. 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 ## Required pre-commit gates
Run every gate below from the repository root before committing. Every command 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
- [ ] Home shows the selected server name, contribution total, 12-month heatmap, - [ ] Home shows the selected server name, contribution total, six labeled
and recent activity. months with gaps between them, and recent activity.
- [ ] Activity rows have the correct icon, repository, summary, and date. - [ ] Activity rows have the correct icon, repository, summary, and date.
- [ ] Tap repository, issue, pull-request, and commit activity. Each opens the - [ ] Tap repository, issue, pull-request, and commit activity. Each opens the
matching native tab and destination. matching native tab and destination.

View File

@@ -149,6 +149,8 @@ pub struct HeatCell {
pub week: u32, pub week: u32,
pub day: u32, pub day: u32,
pub level: u32, pub level: u32,
pub timestamp: i64,
pub contributions: i64,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -585,7 +587,7 @@ fn activity_detail(activity: &models::Activity) -> String {
} }
fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) { fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
const DAYS: i64 = 52 * 7; const DAYS: i64 = 6 * 31;
let latest = data let latest = data
.iter() .iter()
.filter_map(|entry| entry.timestamp) .filter_map(|entry| entry.timestamp)
@@ -613,6 +615,8 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
} else { } else {
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32 ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
}, },
timestamp: (start + index as i64) * 86_400,
contributions: count,
}) })
.collect(); .collect();
(cells, contribution_count) (cells, contribution_count)
@@ -767,8 +771,16 @@ mod tests {
}, },
]; ];
let (cells, total) = heat_cells(&heatmap); let (cells, total) = heat_cells(&heatmap);
assert_eq!((cells.len(), total), (364, 5)); assert_eq!((cells.len(), total), (186, 5));
assert_eq!((cells[362].level, cells[363].level), (1, 4)); assert_eq!(
(
cells[184].level,
cells[184].timestamp,
cells[184].contributions,
cells[185].level,
),
(1, 100 * 86_400, 1, 4)
);
let label = models::Label { let label = models::Label {
name: Some("bug".into()), name: Some("bug".into()),

View File

@@ -1479,13 +1479,17 @@ public struct HeatCell: Equatable, Hashable {
public var week: UInt32 public var week: UInt32
public var day: UInt32 public var day: UInt32
public var level: UInt32 public var level: UInt32
public var timestamp: Int64
public var contributions: Int64
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // 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.week = week
self.day = day self.day = day
self.level = level self.level = level
self.timestamp = timestamp
self.contributions = contributions
} }
@@ -1506,7 +1510,9 @@ public struct FfiConverterTypeHeatCell: FfiConverterRustBuffer {
try HeatCell( try HeatCell(
week: FfiConverterUInt32.read(from: &buf), week: FfiConverterUInt32.read(from: &buf),
day: 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.week, into: &buf)
FfiConverterUInt32.write(value.day, into: &buf) FfiConverterUInt32.write(value.day, into: &buf)
FfiConverterUInt32.write(value.level, 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 // swiftlint:enable all

View File

@@ -403,18 +403,32 @@ final class HomeViewController: RefreshingTableViewController {
final class HeatmapView: UIView { final class HeatmapView: UIView {
private let cells: [HeatCell] private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian)
init(page: HomePage) { 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)) super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 132))
backgroundColor = .systemBackground backgroundColor = .systemBackground
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22)) 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.font = .preferredFont(forTextStyle: .subheadline)
title.textColor = .secondaryLabel title.textColor = .secondaryLabel
addSubview(title) addSubview(title)
let total = UILabel(frame: CGRect(x: 16, y: 104, width: 300, height: 18)) let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
total.text = "\(page.contributionCount) contributions" total.text = "\(cells.reduce(0) { $0 + $1.contributions }) contributions"
total.font = .preferredFont(forTextStyle: .caption1) total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel total.textColor = .tertiaryLabel
addSubview(total) addSubview(total)
@@ -424,9 +438,51 @@ final class HeatmapView: UIView {
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func draw(_ rect: CGRect) { 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 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 { 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] = [ let colors: [UIColor] = [
.systemGray5, .systemGray5,
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1), 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() colors[Int(min(cell.level, 4))].setFill()
UIBezierPath( UIBezierPath(
roundedRect: CGRect( roundedRect: CGRect(
x: 16 + CGFloat(cell.week) * gap, x: 16 + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
y: 43 + CGFloat(cell.day) * gap, y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
width: width, width: width,
height: width height: width
), ),