470 lines
24 KiB
Swift
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)"
|
|
}
|
|
}
|