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

ConditionVisibility
All records same yearHide year headers
All records same monthHide month headers
All records same dayHide day headers, show date once at top
Single tag filter activeHide tag column entirely
Multiple/no tag filtersShow 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

FileAction
Shared/.../DateFormatters.swiftAdd monthName, yearOnly
Views/History/HistoryGrouping.swiftNEW - data structures + grouping logic
Views/History/HistorySection.swiftRefactor to use grouped data
Views/History/YearSectionView.swiftNEW - year section wrapper
Views/History/MonthSectionView.swiftNEW - month section wrapper
Views/History/DaySectionView.swiftNEW - day section wrapper
Views/History/HistoryRecordRow.swiftNEW - extracted record row

Implementation Order

  1. Add DateFormatters (5 min)

    • monthName, yearOnly in Shared
  2. Create HistoryGrouping.swift (30 min)

    • HistoryVisibility, GroupedHistory, group structures
    • groupRecords() function with visibility computation
  3. Create HistoryRecordRow.swift (15 min)

    • Extract from HistorySection
    • Add showTag parameter
  4. Create section wrapper views (30 min)

    • DaySectionView - simplest, start here
    • MonthSectionView - wraps days
    • YearSectionView - wraps months
  5. Refactor HistorySection.swift (30 min)

    • Replace flat ForEach with grouped structure
    • Add common date banner
    • Update caching logic
  6. Test edge cases (20 min)

    • Same day records
    • Same month records
    • Single tag filter
    • Year boundary records

Edge Cases

ScenarioExpected Behavior
All records todayNo date headers, show “Dec 30, 2025” banner at top
All records this monthNo year/month headers, day headers only
All records this yearNo year header, month + day headers
Filter: 1 tag selectedTag column hidden
Filter: 2+ tags selectedTag column visible
Filter: untagged onlyTag 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)