Plan: Smart History Layout
Redesign history section with hierarchical date grouping, sticky headers, and smart visibility.
Current State
Section("History")
[Tag 80px] [Date 70px] [Duration 50px] [Time Range 100px] [Comment flex]
[Tag 80px] [Date 70px] [Duration 50px] [Time Range 100px] [Comment flex]
... - Flat list, no grouping
- Each row repeats full date and tag
- No sticky headers
- Date after tag in layout
Target State
Section("History")
DateRangePicker / TagFilter / Total+Export
Section("2025") <- Year (sticky, only if multiple years)
Section("December") <- Month (sticky, only if multiple months)
Section("Mon 30") <- Day (sticky, only if multiple days)
[Duration] [Time] [Tag?] [Comment]
[Duration] [Time] [Tag?] [Comment]
Section("Sun 29")
[Duration] [Time] [Tag?] [Comment]
Section("November")
... Smart Visibility Rules
| Condition | Visibility |
|---|---|
| All records same year | Hide year headers |
| All records same month | Hide month headers |
| All records same day | Hide day headers, show date once at top |
| Single tag filter active | Hide tag column entirely |
| Multiple/no tag filters | Show tag column |
Data Structures
New: HistoryGrouping.swift (in Views/History/)
/// Visibility flags computed from filtered records
struct HistoryVisibility {
let showYear: Bool // false if all records same year
let showMonth: Bool // false if all records same month
let showDay: Bool // false if all records same day
let showTag: Bool // false if single tag filter
/// Single date to display when all records are same day
let commonDate: Date?
}
/// Grouped records for hierarchical display
struct GroupedHistory {
let visibility: HistoryVisibility
let years: [YearGroup]
}
struct YearGroup: Identifiable {
let year: Int
let months: [MonthGroup]
var id: Int { year }
}
struct MonthGroup: Identifiable {
let year: Int
let month: Int
let days: [DayGroup]
var id: String { "\(year)-\(month)" }
}
struct DayGroup: Identifiable {
let date: Date
let records: [TimeRecord]
var id: Date { date }
} Grouping Function
func groupRecords(
_ records: [TimeRecord],
selectedTagFilters: Set<UUID>,
showUntaggedFilter: Bool
) -> GroupedHistory {
// 1. Compute visibility
let years = Set(records.map { Calendar.current.component(.year, from: $0.startTime) })
let months = Set(records.map {
let c = Calendar.current.dateComponents([.year, .month], from: $0.startTime)
return "\(c.year!)-\(c.month!)"
})
let days = Set(records.map { Calendar.current.startOfDay(for: $0.startTime) })
let showYear = years.count > 1
let showMonth = months.count > 1
let showDay = days.count > 1
let showTag = selectedTagFilters.count != 1 // hide only when exactly 1 tag
let commonDate = days.count == 1 ? days.first : nil
let visibility = HistoryVisibility(
showYear: showYear,
showMonth: showMonth,
showDay: showDay,
showTag: showTag,
commonDate: commonDate
)
// 2. Group records hierarchically
let grouped = Dictionary(grouping: records) { record in
Calendar.current.startOfDay(for: record.startTime)
}
// Build hierarchy...
return GroupedHistory(visibility: visibility, years: yearGroups)
} View Changes
HistorySection.swift
// Add cached grouped data
@State private var groupedHistory: GroupedHistory?
// Update on filter changes
private func updateGroupedRecords() {
let filtered = filterRecords(...)
groupedHistory = groupRecords(filtered, selectedTagFilters, showUntaggedFilter)
}
var body: some View {
Section("History") {
historyDatePicker
if let grouped = groupedHistory {
// Common date banner when all same day
if let commonDate = grouped.visibility.commonDate {
Text(DateFormatters.mediumDate.string(from: commonDate))
.font(.subheadline)
.foregroundStyle(.secondary)
}
// Hierarchical sections
ForEach(grouped.years) { yearGroup in
YearSectionView(
yearGroup: yearGroup,
visibility: grouped.visibility,
editingRecordId: $editingRecordId,
onDelete: deleteHistoryRecord
)
}
}
}
} New: YearSectionView.swift
struct YearSectionView: View {
let yearGroup: YearGroup
let visibility: HistoryVisibility
@Binding var editingRecordId: UUID?
let onDelete: (TimeRecord) async -> Void
var body: some View {
if visibility.showYear {
Section(header: Text(String(yearGroup.year)).font(.headline)) {
monthContent
}
} else {
monthContent
}
}
@ViewBuilder
private var monthContent: some View {
ForEach(yearGroup.months) { monthGroup in
MonthSectionView(...)
}
}
} New: MonthSectionView.swift
struct MonthSectionView: View {
let monthGroup: MonthGroup
let visibility: HistoryVisibility
...
private var monthName: String {
DateFormatters.monthName.string(from: firstDate)
}
var body: some View {
if visibility.showMonth {
Section(header: Text(monthName).font(.subheadline)) {
dayContent
}
} else {
dayContent
}
}
} New: DaySectionView.swift
struct DaySectionView: View {
let dayGroup: DayGroup
let visibility: HistoryVisibility
...
private var dayLabel: String {
// "Mon 30" format
DateFormatters.weekdayAndDay.string(from: dayGroup.date)
}
var body: some View {
if visibility.showDay {
Section(header: Text(dayLabel).font(.caption).foregroundStyle(.secondary)) {
recordRows
}
} else {
recordRows
}
}
private var recordRows: some View {
ForEach(dayGroup.records) { record in
HistoryRecordRow(
record: record,
showTag: visibility.showTag,
isEditing: editingRecordId == record.id,
...
)
}
}
} New: HistoryRecordRow.swift
Extract record row into dedicated component:
struct HistoryRecordRow: View {
let record: TimeRecord
let showTag: Bool
let isEditing: Bool
let onTap: () -> Void
let onDelete: () async -> Void
var body: some View {
if isEditing {
RecordEditor(record: record, ...)
} else {
HStack(spacing: 12) {
// Duration (always show)
Text(formatDuration(record.duration))
.font(.system(.caption, design: .monospaced))
.frame(width: 50, alignment: .trailing)
// Time range (always show)
Text(formatTimeRange(record))
.font(.system(.caption, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 100, alignment: .leading)
// Tag (conditional)
if showTag {
TagBadge(tagId: record.tagId)
.frame(width: 80, alignment: .leading)
}
// Comment
Text(record.comment ?? "")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
}
}
} New DateFormatters
Add to DateFormatters.swift:
/// Month name only: "December"
public static let monthName: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}()
/// Year only: "2025"
public static let yearOnly: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter
}() File Changes Summary
| File | Action |
|---|---|
Shared/.../DateFormatters.swift | Add monthName, yearOnly |
Views/History/HistoryGrouping.swift | NEW - data structures + grouping logic |
Views/History/HistorySection.swift | Refactor to use grouped data |
Views/History/YearSectionView.swift | NEW - year section wrapper |
Views/History/MonthSectionView.swift | NEW - month section wrapper |
Views/History/DaySectionView.swift | NEW - day section wrapper |
Views/History/HistoryRecordRow.swift | NEW - extracted record row |
Implementation Order
Add DateFormatters (5 min)
monthName,yearOnlyin Shared
Create HistoryGrouping.swift (30 min)
HistoryVisibility,GroupedHistory, group structuresgroupRecords()function with visibility computation
Create HistoryRecordRow.swift (15 min)
- Extract from HistorySection
- Add
showTagparameter
Create section wrapper views (30 min)
DaySectionView- simplest, start hereMonthSectionView- wraps daysYearSectionView- wraps months
Refactor HistorySection.swift (30 min)
- Replace flat ForEach with grouped structure
- Add common date banner
- Update caching logic
Test edge cases (20 min)
- Same day records
- Same month records
- Single tag filter
- Year boundary records
Edge Cases
| Scenario | Expected Behavior |
|---|---|
| All records today | No date headers, show “Dec 30, 2025” banner at top |
| All records this month | No year/month headers, day headers only |
| All records this year | No year header, month + day headers |
| Filter: 1 tag selected | Tag column hidden |
| Filter: 2+ tags selected | Tag column visible |
| Filter: untagged only | Tag column visible (shows ”—“) |
| Empty history | “No records” message |
Performance Considerations
- Grouping is O(n) - runs on filter change, acceptable
- List virtualization preserved (ForEach in Sections)
- Visibility flags computed once per filter change
- No additional storage overhead (views reference same records)
Future Enhancements
- Collapse/expand year/month sections
- Jump-to-date navigation
- Swipe actions on day headers (delete all, export day)