test: add 13 Zustand store tests (66 total passing)

This commit is contained in:
saravanakumardb1 2026-02-27 21:15:30 -08:00
parent 815e1cd7fe
commit 755d030c7a
10 changed files with 1435 additions and 0 deletions

View File

@ -0,0 +1,147 @@
// Alarm Overlay
// Full-screen overlay for CRITICAL urgency firing timers
// Requires confirm-to-dismiss
import SwiftUI
struct AlarmOverlay: View {
let timer: CMTimer
@EnvironmentObject var store: TimerStore
@State private var showConfirmDismiss = false
@State private var pulseScale: CGFloat = 1.0
var body: some View {
ZStack {
// Background
CMColors.bg.opacity(0.95)
.ignoresSafeArea()
VStack(spacing: CMSpacing.xxl) {
Spacer()
// Pulsing urgency ring
ZStack {
Circle()
.stroke(CMColors.critical.opacity(0.2), lineWidth: 4)
.frame(width: 200, height: 200)
.scaleEffect(pulseScale)
.animation(
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
value: pulseScale
)
Circle()
.stroke(CMColors.critical, lineWidth: 3)
.frame(width: 160, height: 160)
VStack(spacing: CMSpacing.sm) {
Image(systemName: "bell.fill")
.font(.system(size: 36))
.foregroundStyle(CMColors.critical)
Text("NOW")
.font(CMFonts.mono(size: 24, weight: .bold))
.foregroundStyle(CMColors.critical)
}
}
// Timer info
VStack(spacing: CMSpacing.sm) {
UrgencyBadge(urgency: .critical)
Text(timer.label)
.font(CMFonts.display(size: 28))
.foregroundStyle(CMColors.text)
.multilineTextAlignment(.center)
if let desc = timer.description, !desc.isEmpty {
Text(desc)
.font(CMFonts.body(size: 16))
.foregroundStyle(CMColors.textSecondary)
.multilineTextAlignment(.center)
}
if timer.snoozeCount > 0 {
Text("Snoozed \(timer.snoozeCount) time\(timer.snoozeCount == 1 ? "" : "s")")
.font(CMFonts.body(size: 14))
.foregroundStyle(CMColors.textMuted)
}
}
Spacer()
// Snooze buttons
HStack(spacing: CMSpacing.lg) {
Button {
HapticEngine.tap()
store.snooze(timer.id, minutes: 5)
} label: {
VStack(spacing: CMSpacing.xs) {
Image(systemName: "moon.zzz")
.font(.title3)
Text("5 min")
.font(CMFonts.body(size: 13, weight: .medium))
}
.foregroundStyle(CMColors.text)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.lg)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.md)
.stroke(CMColors.border, lineWidth: 1)
)
}
Button {
HapticEngine.tap()
store.snooze(timer.id, minutes: 15)
} label: {
VStack(spacing: CMSpacing.xs) {
Image(systemName: "moon.zzz.fill")
.font(.title3)
Text("15 min")
.font(CMFonts.body(size: 13, weight: .medium))
}
.foregroundStyle(CMColors.text)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.lg)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.md)
.stroke(CMColors.border, lineWidth: 1)
)
}
}
.padding(.horizontal, CMSpacing.xl)
// Dismiss button (requires confirmation for Critical)
Button {
HapticEngine.tap()
showConfirmDismiss = true
} label: {
Text("Dismiss")
.font(CMFonts.body(size: 18, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.lg)
.background(CMColors.critical)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
}
.padding(.horizontal, CMSpacing.xl)
.padding(.bottom, CMSpacing.xxl)
}
}
.onAppear { pulseScale = 1.15 }
.alert("Dismiss Critical Timer?", isPresented: $showConfirmDismiss) {
Button("Cancel", role: .cancel) {}
Button("Dismiss", role: .destructive) {
store.dismiss(timer.id)
}
} message: {
Text("Are you sure you want to dismiss \"\(timer.label)\"? This is a critical timer.")
}
}
}

View File

@ -0,0 +1,44 @@
// Cascade Progress Bar
import SwiftUI
struct CascadeProgressBar: View {
let fired: Int
let total: Int
let urgency: UrgencyLevel
private var progress: Double {
guard total > 0 else { return 0 }
return Double(fired) / Double(total)
}
var body: some View {
VStack(alignment: .leading, spacing: CMSpacing.xs) {
HStack {
Text("Cascade")
.font(CMFonts.body(size: 11, weight: .medium))
.foregroundStyle(CMColors.textMuted)
Spacer()
Text("\(fired)/\(total)")
.font(CMFonts.mono(size: 11))
.foregroundStyle(CMColors.textMuted)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(CMColors.border)
.frame(height: 6)
RoundedRectangle(cornerRadius: 3)
.fill(CMColors.urgencyColor(urgency))
.frame(width: geo.size.width * progress, height: 6)
.animation(.easeInOut(duration: 0.3), value: progress)
}
}
.frame(height: 6)
}
}
}

View File

@ -0,0 +1,98 @@
// Countdown Ring
// Visual countdown ring (like Time Timer) neurodivergent-friendly
// Color transitions: green yellow orange red as time decreases
import SwiftUI
struct CountdownRing: View {
let progress: Double // 0.0 (full) 1.0 (empty)
let urgency: UrgencyLevel
let remainingSeconds: TimeInterval
let totalSeconds: TimeInterval
var size: CGFloat = 220
var lineWidth: CGFloat = 12
private var ringColor: Color {
// Color transition based on remaining time ratio
let ratio = 1.0 - progress // 1.0 = full time, 0.0 = no time
if ratio > 0.5 {
return CMColors.gentle // green
} else if ratio > 0.25 {
return CMColors.standard // yellow
} else if ratio > 0.1 {
return CMColors.important // orange
} else {
return CMColors.critical // red
}
}
private var glowColor: Color {
ringColor.opacity(0.3)
}
var body: some View {
ZStack {
// Background track
Circle()
.stroke(CMColors.border, lineWidth: lineWidth)
.frame(width: size, height: size)
// Progress arc (fills clockwise from 12 o'clock)
Circle()
.trim(from: 0, to: 1.0 - progress)
.stroke(
ringColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(-90))
.shadow(color: glowColor, radius: 8)
.animation(.easeInOut(duration: 0.3), value: progress)
// Center content
VStack(spacing: CMSpacing.xs) {
Text(formatDuration(remainingSeconds))
.font(CMFonts.mono(size: size * 0.18, weight: .bold))
.foregroundStyle(CMColors.text)
.contentTransition(.numericText())
if totalSeconds > 0 {
Text(formatDurationCompact(totalSeconds))
.font(CMFonts.body(size: size * 0.06))
.foregroundStyle(CMColors.textMuted)
}
}
}
}
}
// MARK: - Mini Countdown Ring (for cards)
struct MiniCountdownRing: View {
let progress: Double
let urgency: UrgencyLevel
var size: CGFloat = 32
var lineWidth: CGFloat = 3
private var ringColor: Color {
CMColors.urgencyColor(urgency)
}
var body: some View {
ZStack {
Circle()
.stroke(CMColors.border, lineWidth: lineWidth)
.frame(width: size, height: size)
Circle()
.trim(from: 0, to: 1.0 - progress)
.stroke(
ringColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.3), value: progress)
}
}
}

View File

@ -0,0 +1,67 @@
// Firing Card
// Inline card for non-critical firing timers
import SwiftUI
struct FiringCard: View {
let timer: CMTimer
@EnvironmentObject var store: TimerStore
@State private var pulse = false
var body: some View {
VStack(spacing: CMSpacing.md) {
HStack {
UrgencyBadge(urgency: timer.urgency)
Spacer()
Text("NOW")
.font(CMFonts.body(size: 12, weight: .bold))
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
.opacity(pulse ? 1.0 : 0.5)
.animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: pulse)
}
Text(timer.label)
.font(CMFonts.body(size: 20, weight: .bold))
.foregroundStyle(CMColors.text)
HStack(spacing: CMSpacing.md) {
Button {
HapticEngine.tap()
store.snooze(timer.id, minutes: 5)
} label: {
Label("Snooze 5m", systemImage: "moon.zzz")
.font(CMFonts.body(size: 14, weight: .medium))
.foregroundStyle(CMColors.text)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.md)
.background(CMColors.surfaceHover)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
Button {
HapticEngine.tap()
store.dismiss(timer.id)
} label: {
Text("Dismiss")
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.md)
.background(CMColors.urgencyColor(timer.urgency))
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
}
.padding(CMSpacing.lg)
.background(CMColors.urgencyBg(timer.urgency))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.lg)
.stroke(CMColors.urgencyBorder(timer.urgency), lineWidth: 2)
)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.lg))
.onAppear { pulse = true }
}
}

View File

@ -0,0 +1,255 @@
// Timer Card
// Individual timer display for timeline list
import SwiftUI
struct TimerCard: View {
let timer: CMTimer
let now: Date
@EnvironmentObject var store: TimerStore
var body: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
// Header: urgency + label + time
HStack(alignment: .top) {
// Urgency indicator dot
Circle()
.fill(CMColors.urgencyColor(timer.urgency))
.frame(width: 8, height: 8)
.padding(.top, 6)
VStack(alignment: .leading, spacing: CMSpacing.xxs) {
Text(timer.label)
.font(CMFonts.body(size: 16, weight: .semibold))
.foregroundStyle(CMColors.text)
.strikethrough(timer.state == .dismissed || timer.state == .completed)
if let desc = timer.description, !desc.isEmpty {
Text(desc)
.font(CMFonts.body(size: 13))
.foregroundStyle(CMColors.textSecondary)
.lineLimit(1)
}
}
Spacer()
// State / countdown
VStack(alignment: .trailing, spacing: CMSpacing.xxs) {
timerStateView
Text(formatTime(timer.targetTime))
.font(CMFonts.body(size: 12))
.foregroundStyle(CMColors.textMuted)
}
}
// Cascade progress (if applicable)
if !timer.warnings.isEmpty && isTimerActive(timer) {
let firedCount = timer.warnings.filter(\.fired).count
CascadeProgressBar(
fired: firedCount,
total: timer.warnings.count,
urgency: timer.urgency
)
}
// Pomodoro round indicator
if timer.type == .pomodoro, let state = timer.pomodoroState, let config = timer.pomodoroConfig {
PomodoroRoundIndicator(
currentRound: state.currentRound,
totalRounds: config.rounds,
completedRounds: state.completedRounds,
isBreak: state.isBreak || state.isLongBreak
)
}
// Action buttons for firing timers
if timer.state == .firing {
HStack(spacing: CMSpacing.sm) {
Button {
HapticEngine.tap()
store.snooze(timer.id, minutes: 5)
} label: {
Label("5m", systemImage: "moon.zzz")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.text)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.surfaceHover)
.clipShape(Capsule())
}
Button {
HapticEngine.tap()
store.snooze(timer.id, minutes: 15)
} label: {
Label("15m", systemImage: "moon.zzz")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.text)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.surfaceHover)
.clipShape(Capsule())
}
Spacer()
Button {
HapticEngine.tap()
store.dismiss(timer.id)
} label: {
Text("Dismiss")
.font(CMFonts.body(size: 13, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, CMSpacing.lg)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.error)
.clipShape(Capsule())
}
}
}
// Action buttons for active/paused timers (countdown/pomodoro)
if [.active, .warning, .paused].contains(timer.state) && timer.type != .alarm {
HStack(spacing: CMSpacing.sm) {
if timer.state == .paused {
Button {
HapticEngine.tap()
store.resume(timer.id)
} label: {
Label("Resume", systemImage: "play.fill")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.accent)
}
} else {
Button {
HapticEngine.tap()
store.pause(timer.id)
} label: {
Label("Pause", systemImage: "pause.fill")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
}
}
Spacer()
Button {
HapticEngine.tap()
store.dismiss(timer.id)
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(CMColors.textMuted)
}
}
}
}
.padding(CMSpacing.lg)
.background(cardBackground)
.overlay(
RoundedRectangle(cornerRadius: CMRadius.md)
.stroke(cardBorder, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
}
// MARK: - Timer State View
@ViewBuilder
private var timerStateView: some View {
switch timer.state {
case .active, .warning:
let remaining = getRemainingSeconds(timer, now: now)
Text(formatDuration(remaining))
.font(CMFonts.mono(size: 16, weight: .semibold))
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
case .paused:
let remaining = getRemainingSeconds(timer, now: now)
HStack(spacing: 4) {
Image(systemName: "pause.fill")
.font(.caption2)
Text(formatDuration(remaining))
.font(CMFonts.mono(size: 16, weight: .semibold))
}
.foregroundStyle(CMColors.textMuted)
case .firing:
Text("FIRING")
.font(CMFonts.body(size: 12, weight: .bold))
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
.padding(.horizontal, CMSpacing.sm)
.padding(.vertical, CMSpacing.xxs)
.background(CMColors.urgencyBg(timer.urgency))
.clipShape(Capsule())
case .snoozed:
if let until = timer.snoozedUntil {
Text("Snoozed → \(formatTime(until))")
.font(CMFonts.body(size: 12))
.foregroundStyle(CMColors.textMuted)
}
case .completed:
Label("Done", systemImage: "checkmark.circle.fill")
.font(CMFonts.body(size: 12, weight: .medium))
.foregroundStyle(CMColors.success)
case .dismissed:
Text("Dismissed")
.font(CMFonts.body(size: 12))
.foregroundStyle(CMColors.textMuted)
case .idle:
EmptyView()
}
}
// MARK: - Card Styling
private var cardBackground: Color {
switch timer.state {
case .firing:
return CMColors.urgencyBg(timer.urgency)
default:
return CMColors.surface
}
}
private var cardBorder: Color {
switch timer.state {
case .firing:
return CMColors.urgencyBorder(timer.urgency)
default:
return CMColors.border
}
}
}
// MARK: - Pomodoro Round Indicator
struct PomodoroRoundIndicator: View {
let currentRound: Int
let totalRounds: Int
let completedRounds: Int
let isBreak: Bool
var body: some View {
HStack(spacing: CMSpacing.sm) {
Text(isBreak ? "Break" : "Round \(currentRound) of \(totalRounds)")
.font(CMFonts.body(size: 12, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
HStack(spacing: CMSpacing.xs) {
ForEach(1...totalRounds, id: \.self) { round in
Circle()
.fill(round <= completedRounds ? CMColors.accent : CMColors.border)
.frame(width: 6, height: 6)
}
}
}
}
}

View File

@ -0,0 +1,23 @@
// Timer List Section
import SwiftUI
struct TimerListSection: View {
let title: String
let timers: [CMTimer]
let now: Date
var body: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
Text(title.uppercased())
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
.padding(.leading, CMSpacing.xs)
ForEach(timers) { timer in
TimerCard(timer: timer, now: now)
}
}
}
}

View File

@ -0,0 +1,20 @@
// Urgency Badge
import SwiftUI
struct UrgencyBadge: View {
let urgency: UrgencyLevel
var body: some View {
Text(getUrgencyConfig(urgency).label.uppercased())
.font(CMFonts.body(size: 10, weight: .bold))
.foregroundStyle(CMColors.urgencyColor(urgency))
.padding(.horizontal, CMSpacing.sm)
.padding(.vertical, CMSpacing.xxs)
.background(CMColors.urgencyBg(urgency))
.clipShape(Capsule())
.overlay(
Capsule().stroke(CMColors.urgencyBorder(urgency), lineWidth: 1)
)
}
}

View File

@ -0,0 +1,451 @@
// Create Timer View
// Modal for creating alarm, countdown, or pomodoro timers
import SwiftUI
struct CreateTimerView: View {
@EnvironmentObject var store: TimerStore
@Environment(\.dismiss) private var dismiss
@State private var timerType: CMTimerType = .countdown
@State private var label = ""
@State private var urgency: UrgencyLevel = .standard
@State private var cascadePreset: CascadePreset = .standard
@State private var category = ""
// Countdown fields
@State private var hours = 0
@State private var minutes = 25
@State private var seconds = 0
// Alarm fields
@State private var alarmDate = Date().addingTimeInterval(3600) // 1h from now
// Pomodoro fields
@State private var workMinutes = 25
@State private var breakMinutes = 5
@State private var longBreakMinutes = 15
@State private var rounds = 4
var body: some View {
NavigationStack {
ZStack {
CMColors.bg.ignoresSafeArea()
ScrollView {
VStack(spacing: CMSpacing.xl) {
// Timer type selector
timerTypePicker
// Label
CMTextField(
title: "Label",
placeholder: placeholderForType,
text: $label
)
// Type-specific fields
switch timerType {
case .countdown:
countdownPicker
case .alarm:
alarmPicker
case .pomodoro:
pomodoroPicker
case .event:
alarmPicker // same UI for now
}
// Urgency selector
urgencySelector
// Cascade preset
cascadeSelector
// Time blindness reference
if let ref = timeReference {
HStack {
Image(systemName: "lightbulb.fill")
.foregroundStyle(CMColors.standard)
.font(.caption)
Text(ref)
.font(CMFonts.body(size: 13))
.foregroundStyle(CMColors.textMuted)
.italic()
}
.padding(CMSpacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
.padding(CMSpacing.lg)
}
}
.navigationTitle("New Timer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(CMColors.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
createTimer()
dismiss()
}
.font(.body.weight(.semibold))
.foregroundStyle(CMColors.accent)
.disabled(!isValid)
}
}
.toolbarBackground(CMColors.surface, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
}
}
// MARK: - Timer Type Picker
private var timerTypePicker: some View {
HStack(spacing: 0) {
ForEach([CMTimerType.countdown, .alarm, .pomodoro], id: \.self) { type in
Button {
HapticEngine.selection()
withAnimation(.easeInOut(duration: 0.2)) {
timerType = type
}
} label: {
Text(type.label)
.font(CMFonts.body(size: 14, weight: timerType == type ? .semibold : .regular))
.foregroundStyle(timerType == type ? CMColors.text : CMColors.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.md)
.background(timerType == type ? CMColors.accent.opacity(0.15) : Color.clear)
}
}
}
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(CMColors.border, lineWidth: 1)
)
}
// MARK: - Countdown Picker
private var countdownPicker: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Duration")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
HStack(spacing: CMSpacing.md) {
durationWheel(title: "Hours", value: $hours, range: 0...23)
durationWheel(title: "Min", value: $minutes, range: 0...59)
durationWheel(title: "Sec", value: $seconds, range: 0...59)
}
.frame(height: 150)
}
}
private func durationWheel(title: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
VStack(spacing: CMSpacing.xs) {
Text(title)
.font(CMFonts.body(size: 11, weight: .medium))
.foregroundStyle(CMColors.textMuted)
Picker(title, selection: value) {
ForEach(range, id: \.self) { n in
Text("\(n)")
.font(CMFonts.mono(size: 20))
.foregroundStyle(CMColors.text)
.tag(n)
}
}
.pickerStyle(.wheel)
}
.frame(maxWidth: .infinity)
}
// MARK: - Alarm Picker
private var alarmPicker: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Date & Time")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
DatePicker(
"Target Time",
selection: $alarmDate,
in: Date()...,
displayedComponents: [.date, .hourAndMinute]
)
.datePickerStyle(.graphical)
.tint(CMColors.accent)
.colorScheme(.dark)
}
}
// MARK: - Pomodoro Picker
private var pomodoroPicker: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
Text("Pomodoro Settings")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
CMStepper(title: "Work", value: $workMinutes, range: 5...90, suffix: "min")
CMStepper(title: "Break", value: $breakMinutes, range: 1...30, suffix: "min")
CMStepper(title: "Long Break", value: $longBreakMinutes, range: 5...60, suffix: "min")
CMStepper(title: "Rounds", value: $rounds, range: 1...12, suffix: "")
// Total time
let totalMinutes = (workMinutes * rounds) + (breakMinutes * (rounds - 1)) + longBreakMinutes
Text("Total: \(formatDurationCompact(TimeInterval(totalMinutes * 60)))")
.font(CMFonts.body(size: 13))
.foregroundStyle(CMColors.textMuted)
}
}
// MARK: - Urgency Selector
private var urgencySelector: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Urgency")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
HStack(spacing: CMSpacing.sm) {
ForEach(UrgencyLevel.allCases) { level in
Button {
HapticEngine.selection()
urgency = level
} label: {
VStack(spacing: CMSpacing.xxs) {
Circle()
.fill(CMColors.urgencyColor(level))
.frame(width: urgency == level ? 24 : 16, height: urgency == level ? 24 : 16)
.overlay(
Circle()
.stroke(Color.white, lineWidth: urgency == level ? 2 : 0)
)
Text(getUrgencyConfig(level).label)
.font(CMFonts.body(size: 10, weight: urgency == level ? .bold : .regular))
.foregroundStyle(urgency == level ? CMColors.text : CMColors.textMuted)
}
.frame(maxWidth: .infinity)
}
.animation(.easeInOut(duration: 0.15), value: urgency)
}
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
// MARK: - Cascade Selector
private var cascadeSelector: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Pre-Warning Cascade")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: CMSpacing.sm) {
ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in
Button {
HapticEngine.selection()
cascadePreset = preset
} label: {
VStack(spacing: CMSpacing.xxs) {
Text(preset.label)
.font(CMFonts.body(size: 13, weight: cascadePreset == preset ? .semibold : .regular))
.foregroundStyle(cascadePreset == preset ? CMColors.text : CMColors.textMuted)
let intervals = preset.defaultIntervals
if intervals.isEmpty {
Text("")
.font(CMFonts.body(size: 10))
.foregroundStyle(CMColors.textMuted)
} else {
Text(intervals.map { formatMinutesBefore($0) }.joined(separator: ", "))
.font(CMFonts.body(size: 9))
.foregroundStyle(CMColors.textMuted)
.lineLimit(1)
}
}
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(cascadePreset == preset ? CMColors.accent.opacity(0.15) : CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(cascadePreset == preset ? CMColors.accent.opacity(0.4) : CMColors.border, lineWidth: 1)
)
}
}
}
}
}
}
// MARK: - Helpers
private var placeholderForType: String {
switch timerType {
case .countdown: return "e.g., Pasta timer"
case .alarm: return "e.g., Team standup"
case .pomodoro: return "e.g., Deep work session"
case .event: return "e.g., Vacation countdown"
}
}
private var timeReference: String? {
switch timerType {
case .countdown:
let totalSeconds = (hours * 3600) + (minutes * 60) + seconds
return getTimeReference(seconds: TimeInterval(totalSeconds))
case .pomodoro:
return getTimeReference(minutes: workMinutes)
default:
return nil
}
}
private var isValid: Bool {
switch timerType {
case .countdown:
return (hours + minutes + seconds) > 0
case .alarm, .event:
return alarmDate > Date()
case .pomodoro:
return workMinutes > 0
}
}
private func createTimer() {
let timerLabel = label.isEmpty ? placeholderForType.replacingOccurrences(of: "e.g., ", with: "") : label
let cascadeConfig = CascadeConfig(preset: cascadePreset, intervals: [])
switch timerType {
case .countdown:
let totalSeconds = TimeInterval((hours * 3600) + (minutes * 60) + seconds)
let _ = store.addCountdown(CreateCountdownParams(
label: timerLabel,
durationSeconds: totalSeconds,
urgency: urgency,
cascade: cascadeConfig,
category: category.isEmpty ? nil : category
))
case .alarm, .event:
let _ = store.addAlarm(CreateAlarmParams(
label: timerLabel,
targetTime: alarmDate,
urgency: urgency,
cascade: cascadeConfig,
category: category.isEmpty ? nil : category
))
case .pomodoro:
let config = PomodoroConfig(
workMinutes: workMinutes,
breakMinutes: breakMinutes,
longBreakMinutes: longBreakMinutes,
rounds: rounds
)
let _ = store.addPomodoro(CreatePomodoroParams(
label: timerLabel,
config: config,
urgency: urgency
))
}
HapticEngine.tap()
}
}
// MARK: - CM Text Field
struct CMTextField: View {
let title: String
let placeholder: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text(title)
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
TextField(placeholder, text: $text)
.font(CMFonts.body(size: 16))
.foregroundStyle(CMColors.text)
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(CMColors.border, lineWidth: 1)
)
}
}
}
// MARK: - CM Stepper
struct CMStepper: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
let suffix: String
var body: some View {
HStack {
Text(title)
.font(CMFonts.body(size: 14))
.foregroundStyle(CMColors.textSecondary)
Spacer()
HStack(spacing: CMSpacing.md) {
Button {
if value > range.lowerBound {
value -= 1
HapticEngine.selection()
}
} label: {
Image(systemName: "minus.circle.fill")
.font(.title3)
.foregroundStyle(value > range.lowerBound ? CMColors.textSecondary : CMColors.textMuted)
}
.disabled(value <= range.lowerBound)
Text("\(value)\(suffix.isEmpty ? "" : " \(suffix)")")
.font(CMFonts.mono(size: 16, weight: .semibold))
.foregroundStyle(CMColors.text)
.frame(minWidth: 60)
Button {
if value < range.upperBound {
value += 1
HapticEngine.selection()
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title3)
.foregroundStyle(value < range.upperBound ? CMColors.accent : CMColors.textMuted)
}
.disabled(value >= range.upperBound)
}
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}

View File

@ -0,0 +1,92 @@
// Quick Timer Sheet
// Bottom sheet with one-tap preset timers
import SwiftUI
struct QuickTimerSheet: View {
@EnvironmentObject var store: TimerStore
@Environment(\.dismiss) private var dismiss
private let presets: [(String, Int, String, UrgencyLevel)] = [
("1 min", 1, "timer", .gentle),
("3 min", 3, "timer", .gentle),
("5 min", 5, "timer", .gentle),
("10 min", 10, "timer", .standard),
("15 min", 15, "timer", .standard),
("20 min", 20, "timer", .standard),
("25 min", 25, "Pomodoro", .standard),
("30 min", 30, "timer", .standard),
("45 min", 45, "timer", .important),
("1 hour", 60, "timer", .important),
("90 min", 90, "timer", .important),
("2 hours", 120, "timer", .important),
]
let columns = [
GridItem(.flexible(), spacing: CMSpacing.md),
GridItem(.flexible(), spacing: CMSpacing.md),
GridItem(.flexible(), spacing: CMSpacing.md),
]
var body: some View {
NavigationStack {
ZStack {
CMColors.bg.ignoresSafeArea()
ScrollView {
LazyVGrid(columns: columns, spacing: CMSpacing.md) {
ForEach(presets, id: \.0) { label, minutes, name, urgency in
Button {
HapticEngine.tap()
let _ = store.addCountdown(CreateCountdownParams(
label: "\(label) \(name)",
durationSeconds: TimeInterval(minutes * 60),
urgency: urgency,
cascade: CascadeConfig(
preset: minutes >= 30 ? .light : .minimal,
intervals: []
)
))
dismiss()
} label: {
VStack(spacing: CMSpacing.xs) {
Text(label)
.font(CMFonts.mono(size: 18, weight: .semibold))
.foregroundStyle(CMColors.text)
// Time reference
if let ref = getTimeReference(minutes: minutes) {
Text(ref.replacingOccurrences(of: "About as long as ", with: ""))
.font(CMFonts.body(size: 10))
.foregroundStyle(CMColors.textMuted)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.lg)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.md)
.stroke(CMColors.border, lineWidth: 1)
)
}
}
}
.padding(CMSpacing.lg)
}
}
.navigationTitle("Quick Timer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(CMColors.textSecondary)
}
}
.toolbarBackground(CMColors.surface, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
}
.presentationDetents([.medium, .large])
}
}

238
web/src/lib/store.test.ts Normal file
View File

@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock browser APIs that the store depends on
vi.mock('./sounds', () => ({
playAlarmSound: vi.fn(),
playWarningChime: vi.fn(),
}));
vi.mock('./notifications', () => ({
sendFireNotification: vi.fn(),
sendWarningNotification: vi.fn(),
}));
// Mock zustand persist middleware as a pass-through (no actual storage in tests)
vi.mock('zustand/middleware', async () => {
const actual = await vi.importActual<typeof import('zustand/middleware')>('zustand/middleware');
return {
...actual,
persist: (fn: unknown) => fn,
createJSONStorage: actual.createJSONStorage,
};
});
import { useTimerStore } from './store';
describe('TimerStore', () => {
beforeEach(() => {
useTimerStore.setState({ timers: [], now: Date.now() });
});
describe('addAlarm', () => {
it('creates an alarm timer and adds it to the store', () => {
const { addAlarm } = useTimerStore.getState();
const timer = addAlarm({
label: 'Test Alarm',
targetTime: Date.now() + 60_000,
urgency: 'standard',
cascade: { preset: 'light', intervals: [] },
});
expect(timer.type).toBe('alarm');
expect(timer.label).toBe('Test Alarm');
expect(timer.state).toBe('active');
const { timers } = useTimerStore.getState();
expect(timers).toHaveLength(1);
expect(timers[0].id).toBe(timer.id);
});
});
describe('addCountdown', () => {
it('creates a countdown timer', () => {
const { addCountdown } = useTimerStore.getState();
const timer = addCountdown({
label: '5 min timer',
durationMs: 5 * 60_000,
urgency: 'gentle',
cascade: { preset: 'minimal', intervals: [] },
});
expect(timer.type).toBe('countdown');
expect(timer.duration).toBe(5 * 60_000);
expect(timer.state).toBe('active');
const { timers } = useTimerStore.getState();
expect(timers).toHaveLength(1);
});
});
describe('addPomodoro', () => {
it('creates a pomodoro timer with defaults', () => {
const { addPomodoro } = useTimerStore.getState();
const timer = addPomodoro();
expect(timer.type).toBe('pomodoro');
expect(timer.pomodoroConfig).toBeDefined();
expect(timer.pomodoroConfig!.workMinutes).toBe(25);
expect(timer.pomodoroConfig!.rounds).toBe(4);
expect(timer.state).toBe('active');
});
it('creates a pomodoro timer with custom config', () => {
const { addPomodoro } = useTimerStore.getState();
const timer = addPomodoro({ label: 'Deep Work', config: { workMinutes: 50, breakMinutes: 10 } });
expect(timer.label).toBe('Deep Work');
expect(timer.pomodoroConfig!.workMinutes).toBe(50);
expect(timer.pomodoroConfig!.breakMinutes).toBe(10);
});
});
describe('removeTimer', () => {
it('removes a timer by id', () => {
const { addCountdown, removeTimer } = useTimerStore.getState();
const timer = addCountdown({
label: 'To remove',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
removeTimer(timer.id);
const { timers } = useTimerStore.getState();
expect(timers).toHaveLength(0);
});
});
describe('state transitions', () => {
it('pauses and resumes a countdown', () => {
const { addCountdown, pause, resume } = useTimerStore.getState();
const timer = addCountdown({
label: 'Pausable',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
pause(timer.id);
expect(useTimerStore.getState().timers[0].state).toBe('paused');
resume(timer.id);
expect(useTimerStore.getState().timers[0].state).toBe('active');
});
it('dismisses a timer', () => {
const { addCountdown, dismiss } = useTimerStore.getState();
const timer = addCountdown({
label: 'Dismissable',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
dismiss(timer.id);
expect(useTimerStore.getState().timers[0].state).toBe('dismissed');
});
});
describe('tick', () => {
it('fires a timer when its target time is reached', () => {
const { addCountdown, tick } = useTimerStore.getState();
const timer = addCountdown({
label: 'Fire me',
durationMs: 1_000, // 1 second
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
// Tick past the target time
const futureTime = timer.targetTime + 100;
tick(futureTime);
const updated = useTimerStore.getState().timers[0];
expect(updated.state).toBe('firing');
});
it('does not fire a timer before its target time', () => {
const { addCountdown, tick } = useTimerStore.getState();
const timer = addCountdown({
label: 'Not yet',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
// Tick before target time
tick(timer.targetTime - 30_000);
const updated = useTimerStore.getState().timers[0];
expect(updated.state).toBe('active');
});
it('updates now on every tick', () => {
const { tick } = useTimerStore.getState();
const time = Date.now() + 5000;
tick(time);
expect(useTimerStore.getState().now).toBe(time);
});
});
describe('helpers', () => {
it('getTimer returns correct timer', () => {
const { addCountdown, getTimer } = useTimerStore.getState();
const timer = addCountdown({
label: 'Find me',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
const found = getTimer(timer.id);
expect(found).toBeDefined();
expect(found!.label).toBe('Find me');
});
it('getActiveTimers returns only active timers', () => {
const { addCountdown, dismiss, getActiveTimers } = useTimerStore.getState();
addCountdown({
label: 'Active',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
const t2 = addCountdown({
label: 'To dismiss',
durationMs: 60_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
dismiss(t2.id);
const active = getActiveTimers();
expect(active).toHaveLength(1);
expect(active[0].label).toBe('Active');
});
it('getNextFiringTimer returns timer with soonest target', () => {
const { addCountdown, getNextFiringTimer } = useTimerStore.getState();
addCountdown({
label: 'Later',
durationMs: 120_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
addCountdown({
label: 'Sooner',
durationMs: 30_000,
urgency: 'standard',
cascade: { preset: 'none', intervals: [] },
});
const next = getNextFiringTimer();
expect(next).toBeDefined();
expect(next!.label).toBe('Sooner');
});
});
});