Files
nucleic/Sources/NucleicApp/HomeView.swift
T

470 lines
24 KiB
Swift

import SwiftUI
import Foundation
import NucleicCore
import NucleicProtocol
/// The home dashboard shown when no chat is selected: a greeting, rollup stats,
/// and a GitHub-style activity grid of usage over the last several weeks.
struct HomeView: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@AppStorage(StreakBadge.showKey) private var showStreak = true
/// Measured height of the activity column, mirrored onto the usage card so the two
/// sit at the same height (the card's content is shorter, so it stretches to match).
@State private var activityHeight: CGFloat = 0
/// Measured width of the activity/usage row, so it can reflow from side-by-side to
/// a vertical stack once there isn't room to hold both columns.
@State private var dashboardWidth: CGFloat = 0
/// Below this content width the usage card drops beneath the activity card instead
/// of being squeezed up over it.
private let dashboardStackThreshold: CGFloat = 600
/// Current activity streak with streak freezes folded in (see `StreakState`): the run of
/// consecutive days with at least one message, where a banked freeze (one earned per five
/// days) bridges a missed day. Drives the badge and tells the grid which missed days a
/// freeze kept alive (rendered blue). Derived from the same activity data as the grid.
private var streakState: StreakState {
StreakState.compute(activityByDay: store.activityByDay)
}
private var firstName: String {
if let first = NSFullUserName().split(separator: " ").first, !first.isEmpty {
return String(first)
}
let login = NSUserName()
return login.isEmpty ? "there" : login
}
var body: some View {
@Bindable var store = store
// Computed once per render and threaded down, so the badge and the grid agree on the
// streak and which days a freeze bridged without recomputing it several times.
let streak = streakState
return VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Hi, \(firstName)")
.font(.system(size: 36, weight: .bold))
Text(subtitle).foregroundStyle(.secondary)
}
if showStreak {
Spacer(minLength: 16)
StreakBadge(days: streak.streak, freezes: streak.availableFreezes)
}
}
// Offers to ride out a provider outage on a comparable model from another
// provider (and to switch back on recovery). Hidden unless the default
// model's provider is reporting an incident or a failover is in effect.
ModelFailoverBanner()
dashboardRow(frozenDays: streak.frozenDays)
// Mesh session sync: fold connected peer Macs' counts into the union, so every
// Mac in the mesh shows the same totals (`meshDashboard` == local when solo).
let stats = store.meshDashboard
HStack(spacing: 16) {
StatCard(value: stats.projects, label: "Projects", icon: "folder")
StatCard(value: stats.chats, label: "Chats", icon: "bubble.left.and.bubble.right")
StatCard(value: stats.activeChats, label: "Active", icon: "bolt")
StatCard(value: stats.messages, label: "Messages", icon: "paperplane")
StatCard(
values: TokenCount.adaptiveCandidates(stats.tokens),
label: "Tokens", icon: "text.word.spacing",
help: "\(stats.tokens.formatted()) tokens used across all chats")
}
TodoSection()
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollContentBackground(.hidden)
Divider()
// The dashboard's pinned "start a new chat" bar — the same composer the ⌘⇧N
// floating panel presents, bound here to the persisted home draft.
NewChatComposer(draft: $store.homeDraft)
}
.task { await store.loadDashboard() }
.task { await store.loadTodos() }
}
/// Activity and usage cards: side by side when there's room, but once the row
/// narrows past `dashboardStackThreshold` the usage card drops below the activity
/// card rather than being squeezed up over it.
private func dashboardRow(frozenDays: Set<Date>) -> some View {
let stacked = dashboardWidth > 0 && dashboardWidth < dashboardStackThreshold
let layout = stacked
? AnyLayout(VStackLayout(alignment: .leading, spacing: 28))
: AnyLayout(HStackLayout(alignment: .top, spacing: 28))
return layout {
activityColumn(frozenDays: frozenDays)
usageColumn(stacked: stacked)
.frame(maxWidth: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(GeometryReader { proxy in
Color.clear
.onAppear { dashboardWidth = proxy.size.width }
.onChange(of: proxy.size.width) { _, width in dashboardWidth = width }
})
}
/// The usage column beside the activity chart: the Claude quota card, plus the Codex card
/// stacked beneath once a Codex chat has reported usage (hidden for Claude-only users so the
/// dashboard stays uncluttered). The height-match to the activity chart applies only to a
/// lone Claude card shown side-by-side; when the Codex card joins, both size to their content.
@ViewBuilder
private func usageColumn(stacked: Bool) -> some View {
let hasCodex = store.codexUsage != nil
VStack(spacing: 16) {
QuotaCard(matchHeight: (stacked || hasCodex) ? 0 : activityHeight)
if hasCodex {
CodexUsageCard()
}
}
}
private func activityColumn(frozenDays: Set<Date>) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity").font(.headline)
ActivityGrid(
activityByDay: store.activityByDay, tokensByDay: store.tokensByDay,
frozenDays: frozenDays)
HStack(spacing: 5) {
Text("Less").font(.caption2).foregroundStyle(.secondary)
ForEach(0..<4, id: \.self) { level in
RoundedRectangle(cornerRadius: 2)
.fill(palette.activity(level: level))
.frame(width: 11, height: 11)
}
Text("More").font(.caption2).foregroundStyle(.secondary)
// A streak freeze covered a missed day, keeping the run alive — set apart from
// the activity ramp so the blue reads as "saved", not "more active".
Spacer().frame(width: 12)
RoundedRectangle(cornerRadius: 2)
.fill(palette.frozen)
.frame(width: 11, height: 11)
Text("Frozen").font(.caption2).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(GeometryReader { proxy in
Color.clear
.onAppear { activityHeight = proxy.size.height }
.onChange(of: proxy.size.height) { _, height in activityHeight = height }
})
}
private var subtitle: String {
let stats = store.meshDashboard
if stats.chats == 0 {
return "No chats yet — pick a project below and start one from the chat bar."
}
let projectWord = stats.projects == 1 ? "project" : "projects"
return "\(stats.activeChats) active of \(stats.chats) chats across \(stats.projects) \(projectWord)."
}
}
/// A day-streak indicator aligned to the greeting: a lightning bolt and the run length.
/// The bolt charges hotter — bigger, brighter, with a stronger electric glow — as
/// the streak lengthens. Dims to grey when the streak is broken (0 days).
struct StreakBadge: View {
static let showKey = "nucleic.home.showStreak"
let days: Int
/// Streak freezes in reserve (one earned per five days), each able to cover a missed day.
/// Surfaced only in the tooltip (`helpText`), not as an on-card counter.
var freezes: Int = 0
/// Drives the repeating charge pulse; toggled on appear so the animation runs forever.
@State private var charged = false
/// 0 for a one-day streak, ramping to 1 around three weeks — how charged to glow.
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
/// Electric gradient from a hot white tip down to a deep electric blue base. Short
/// streaks read as a muted steel-blue spark; long ones as a vivid cyan→blue arc.
private var boltGradient: LinearGradient {
func lerp(_ lo: Double, _ hi: Double) -> Double { lo + (hi - lo) * intensity }
let tip = Color(hue: lerp(0.55, 0.50), saturation: lerp(0.40, 0.70), brightness: 1.0)
let mid = Color(hue: lerp(0.58, 0.54), saturation: lerp(0.70, 0.95), brightness: 1.0)
let base = Color(hue: lerp(0.62, 0.60), saturation: lerp(0.85, 1.0), brightness: lerp(0.85, 1.0))
return LinearGradient(colors: [tip, mid, base], startPoint: .top, endPoint: .bottom)
}
/// One pulse cycle: a lazy ~1.5s throb for a fresh streak, accelerating to a frantic
/// ~0.35s flicker once fully charged — the higher the streak, the more electric.
private var pulsePeriod: Double { 1.5 - 1.15 * intensity }
var body: some View {
let active = days > 0
// Pulse amplitudes scale with the streak: a barely-there flutter early, a hard
// strobe of brightness and glow by the time it's fully charged. The pulse is purely
// a brightness/glow throb — no scale or rotation — so the bolt never shifts position
// and stays horizontally fixed rather than swinging side to side.
let pulsing = active && charged
// The bolt never fades back to base: it rests brighter than base and pulses
// brighter still, so the animation only ever lights it up further.
let restBright = active ? 0.08 + 0.10 * intensity : 0
let peakBright = active ? 0.22 + 0.45 * intensity : 0
// Glow likewise rests boosted and crests stronger — never collapsing to base.
let glowFactor = active ? (pulsing ? 1.4 + 1.5 * intensity : 1.2 + 0.4 * intensity) : 1
return HStack(spacing: 8) {
Image(systemName: "bolt.fill")
// Grows from ~22pt to ~32pt as the charge intensifies.
.font(.system(size: 22 + 10 * intensity))
.foregroundStyle(active ? AnyShapeStyle(boltGradient) : AnyShapeStyle(Color.secondary))
.brightness(pulsing ? peakBright : restBright)
.shadow(color: active ? Color(hue: 0.56, saturation: 0.85, brightness: 1.0).opacity((0.30 + 0.50 * intensity) * glowFactor) : .clear,
radius: active ? (1 + 7 * intensity) * glowFactor : 0)
.animation(active ? .easeInOut(duration: pulsePeriod).repeatForever(autoreverses: true) : .default,
value: pulsing)
.onAppear { charged = true }
// A fixed leading slot pins the bolt in place: the glyph grows with the streak
// but its footprint doesn't, so it stays locked to the left of the count and
// never drifts into or overlaps the "day streak" label.
.frame(width: 32, alignment: .center)
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\(days)")
.font(.system(size: 26, weight: .semibold).monospacedDigit())
Text("day streak")
.font(.system(size: 26, weight: .semibold).monospacedDigit())
.foregroundStyle(.secondary)
}
}
.opacity(active ? 1 : 0.55)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
.help(helpText)
}
/// Tooltip: the streak length plus how freezes stand — how many are banked, or how soon
/// the next one is earned — so the mechanic is discoverable from the badge alone.
private var helpText: String {
guard days > 0 else { return "No active streak — send a message today to start one." }
let base = "\(days)-day activity streak — consecutive days with at least one message, "
+ "with streak freezes bridging any missed day."
if freezes > 0 {
let s = freezes == 1 ? "" : "s"
return base + " \(freezes) freeze\(s) banked — each covers one missed day."
}
let toNext = StreakState.freezeEveryDays - (days % StreakState.freezeEveryDays)
let dayWord = toNext == 1 ? "day" : "days"
return base + " Earn a freeze every \(StreakState.freezeEveryDays) days — \(toNext) \(dayWord) to the next."
}
}
private struct StatCard: View {
/// Candidate renderings of the value, widest first: the card shows the fullest one its width
/// allows (via `ViewThatFits`) and falls back to the narrowest. A single entry for the plain
/// integer cards; several for the token card, so a wide card shows more digits than "1.6B".
let values: [String]
let label: String
let icon: String
/// Help text shown on hover — used to spell out an abbreviated value (e.g. the exact token
/// count behind "12.3K"). Nil for the plain integer cards, whose value is already exact.
var help: String?
init(value: Int, label: String, icon: String) {
self.init(values: [value.formatted()], label: label, icon: icon)
}
init(value: String, label: String, icon: String, help: String? = nil) {
self.init(values: [value], label: label, icon: icon, help: help)
}
init(values: [String], label: String, icon: String, help: String? = nil) {
self.values = values.isEmpty ? [""] : values
self.label = label
self.icon = icon
self.help = help
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: icon).foregroundStyle(.secondary)
// Show the fullest value the card's width fits, shrinking toward the compact form when
// narrow; ViewThatFits renders the last candidate if none fit, so it never overflows.
ViewThatFits(in: .horizontal) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
Text(value).font(.system(size: 26, weight: .semibold).monospacedDigit())
.lineLimit(1)
}
}
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
.help(help ?? "")
}
}
/// Squares for the last `weeks` weeks, columns = weeks (old → new), rows = Sun…Sat,
/// shaded by that day's token usage — or, for days with no recorded usage, its message count
/// (see `ActivityShading`).
struct ActivityGrid: View {
@Environment(\.appPalette) private var palette
let activityByDay: [Date: Int]
/// Tokens used per day — the primary intensity signal. Days absent here fall back to their
/// `activityByDay` message count, so older transcripts still shade sensibly.
var tokensByDay: [Date: Int] = [:]
/// Missed days a streak freeze kept alive — drawn in the frozen blue instead of the
/// empty-day gray, so a bridged gap reads as "saved" rather than skipped.
var frozenDays: Set<Date> = []
/// Brightness scales for the whole grid, computed once per data change (at init). This
/// used to be built inside the render pass, which re-derived both percentile scales on
/// every body evaluation — including each hover enter/leave over the grid's cells.
private let shading: ActivityShading
init(activityByDay: [Date: Int], tokensByDay: [Date: Int] = [:], frozenDays: Set<Date> = []) {
self.activityByDay = activityByDay
self.tokensByDay = tokensByDay
self.frozenDays = frozenDays
self.shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: activityByDay)
}
private let cell: CGFloat = 16
private let gap: CGFloat = 3
private let minWeeks = 16
private var step: CGFloat { cell + gap }
private var gridHeight: CGFloat { cell * 7 + gap * 6 }
/// The square the pointer is currently over — its day plus where it sits in the grid, so
/// the floating tooltip can be parked against that exact cell. Nil when the pointer is
/// outside the grid.
@State private var hovered: HoveredCell?
private struct HoveredCell: Equatable {
let date: Date
let count: Int
let tokens: Int
let isFrozen: Bool
let column: Int
let row: Int
}
var body: some View {
GeometryReader { geo in
let weeks = max(minWeeks, Int((geo.size.width + gap) / (cell + gap)))
let width = geo.size.width
grid(weeks: weeks)
// Four anchored overlays let the bubble pick the corner that keeps it on-grid:
// top rows drop the tip below the square, lower rows raise it above; cells past
// the midline grow leftward, the rest rightward. Anchoring to a known edge means
// no measured tooltip size is needed to position it.
.overlay(alignment: .topLeading) { tooltip(.topLeading, width: width) }
.overlay(alignment: .topTrailing) { tooltip(.topTrailing, width: width) }
.overlay(alignment: .bottomLeading) { tooltip(.bottomLeading, width: width) }
.overlay(alignment: .bottomTrailing) { tooltip(.bottomTrailing, width: width) }
}
.frame(height: gridHeight)
}
private func grid(weeks: Int) -> some View {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let weekday = calendar.component(.weekday, from: today) // 1 = Sun … 7 = Sat
let startOfThisWeek = calendar.date(byAdding: .day, value: -(weekday - 1), to: today)!
let gridStart = calendar.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfThisWeek)!
// Brightness is relative to *this user's* activity, with outliers excluded so a few
// marathon days don't crush the everyday range. Shades by tokens-per-day, falling back
// to message count for days without recorded usage. `shading` is computed once at init.
return HStack(alignment: .top, spacing: gap) {
ForEach(0..<weeks, id: \.self) { column in
VStack(spacing: gap) {
ForEach(0..<7, id: \.self) { row in
let date = calendar.date(byAdding: .day, value: column * 7 + row, to: gridStart)!
cellView(date: date, today: today, shading: shading, column: column, row: row)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func cellView(date: Date, today: Date, shading: ActivityShading, column: Int, row: Int) -> some View {
let isFuture = date > today
let isFrozen = !isFuture && frozenDays.contains(date)
let count = activityByDay[date] ?? 0
let tokens = tokensByDay[date] ?? 0
let fill = isFuture ? Color.clear
: (isFrozen ? palette.frozen
: palette.activity(intensity: shading.intensity(tokens: tokens, messages: count)))
let isHovered = hovered?.date == date
return RoundedRectangle(cornerRadius: 2)
.fill(fill)
.frame(width: cell, height: cell)
// A faint outline marks the square the tooltip is describing.
.overlay(RoundedRectangle(cornerRadius: 2)
.strokeBorder(Color.primary.opacity(isHovered ? 0.35 : 0), lineWidth: 1))
.accessibilityLabel(isFuture ? "" : Self.tooltipText(date: date, count: count, tokens: tokens, isFrozen: isFrozen))
.onHover { inside in
guard !isFuture else { return }
if inside {
hovered = HoveredCell(date: date, count: count, tokens: tokens, isFrozen: isFrozen, column: column, row: row)
} else if hovered?.date == date {
hovered = nil
}
}
}
/// The floating tooltip for the hovered square, rendered only by the anchored overlay whose
/// corner keeps the bubble on-grid (see the overlay comment in `body`).
@ViewBuilder
private func tooltip(_ corner: Alignment, width: CGFloat) -> some View {
if let h = hovered {
let below = h.row <= 1
let trailing = (CGFloat(h.column) * step + cell / 2) > width / 2
let wantCorner: Alignment = below
? (trailing ? .topTrailing : .topLeading)
: (trailing ? .bottomTrailing : .bottomLeading)
if corner == wantCorner {
// Leading corners anchor the bubble's left edge to the cell's left; trailing
// corners anchor its right edge to the cell's right. Below corners sit just under
// the cell; above corners (bottom-anchored) just over it.
let x = trailing ? CGFloat(h.column) * step + cell - width
: CGFloat(h.column) * step
let y = below ? CGFloat(h.row) * step + cell + gap
: CGFloat(h.row) * step - gap - gridHeight
bubble(h).offset(x: x, y: y).allowsHitTesting(false)
}
}
}
private func bubble(_ h: HoveredCell) -> some View {
Text(Self.tooltipText(date: h.date, count: h.count, tokens: h.tokens, isFrozen: h.isFrozen))
.font(.caption)
.foregroundStyle(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(AppTheme.surface, in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(AppTheme.hairline, lineWidth: 1))
.shadow(color: .black.opacity(0.18), radius: 6, y: 1)
.fixedSize()
}
private static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
static func dayLabel(_ date: Date) -> String { formatter.string(from: date) }
/// "Jun 12, 2026 · 12.3K tokens · 5 messages" — the day with its token usage (when recorded)
/// and message count, or a streak-freeze note. Tokens lead since they drive the shade; the
/// clause is dropped on days with no recorded usage so older days still read cleanly.
static func tooltipText(date: Date, count: Int, tokens: Int, isFrozen: Bool) -> String {
if isFrozen { return "\(dayLabel(date)) · Streak freeze" }
let messages = "\(count) message\(count == 1 ? "" : "s")"
guard tokens > 0 else { return "\(dayLabel(date)) · \(messages)" }
return "\(dayLabel(date)) · \(TokenCount.abbreviated(tokens)) tokens · \(messages)"
}
}