From 755d030c7ae78167dc308d36870240eee1394771 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 21:15:30 -0800 Subject: [PATCH] test: add 13 Zustand store tests (66 total passing) --- .../Views/Components/AlarmOverlay.swift | 147 ++++++ .../Views/Components/CascadeProgressBar.swift | 44 ++ .../Views/Components/CountdownRing.swift | 98 ++++ .../Views/Components/FiringCard.swift | 67 +++ .../Views/Components/TimerCard.swift | 255 ++++++++++ .../Views/Components/TimerListSection.swift | 23 + .../Views/Components/UrgencyBadge.swift | 20 + .../Views/CreateTimer/CreateTimerView.swift | 451 ++++++++++++++++++ .../Views/CreateTimer/QuickTimerSheet.swift | 92 ++++ web/src/lib/store.test.ts | 238 +++++++++ 10 files changed, 1435 insertions(+) create mode 100644 ios/ChronoMind/Views/Components/AlarmOverlay.swift create mode 100644 ios/ChronoMind/Views/Components/CascadeProgressBar.swift create mode 100644 ios/ChronoMind/Views/Components/CountdownRing.swift create mode 100644 ios/ChronoMind/Views/Components/FiringCard.swift create mode 100644 ios/ChronoMind/Views/Components/TimerCard.swift create mode 100644 ios/ChronoMind/Views/Components/TimerListSection.swift create mode 100644 ios/ChronoMind/Views/Components/UrgencyBadge.swift create mode 100644 ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift create mode 100644 ios/ChronoMind/Views/CreateTimer/QuickTimerSheet.swift create mode 100644 web/src/lib/store.test.ts diff --git a/ios/ChronoMind/Views/Components/AlarmOverlay.swift b/ios/ChronoMind/Views/Components/AlarmOverlay.swift new file mode 100644 index 0000000..c3a8339 --- /dev/null +++ b/ios/ChronoMind/Views/Components/AlarmOverlay.swift @@ -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.") + } + } +} diff --git a/ios/ChronoMind/Views/Components/CascadeProgressBar.swift b/ios/ChronoMind/Views/Components/CascadeProgressBar.swift new file mode 100644 index 0000000..473189d --- /dev/null +++ b/ios/ChronoMind/Views/Components/CascadeProgressBar.swift @@ -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) + } + } +} diff --git a/ios/ChronoMind/Views/Components/CountdownRing.swift b/ios/ChronoMind/Views/Components/CountdownRing.swift new file mode 100644 index 0000000..25142eb --- /dev/null +++ b/ios/ChronoMind/Views/Components/CountdownRing.swift @@ -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) + } + } +} diff --git a/ios/ChronoMind/Views/Components/FiringCard.swift b/ios/ChronoMind/Views/Components/FiringCard.swift new file mode 100644 index 0000000..d49bdb9 --- /dev/null +++ b/ios/ChronoMind/Views/Components/FiringCard.swift @@ -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 } + } +} diff --git a/ios/ChronoMind/Views/Components/TimerCard.swift b/ios/ChronoMind/Views/Components/TimerCard.swift new file mode 100644 index 0000000..905f0ec --- /dev/null +++ b/ios/ChronoMind/Views/Components/TimerCard.swift @@ -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) + } + } + } + } +} diff --git a/ios/ChronoMind/Views/Components/TimerListSection.swift b/ios/ChronoMind/Views/Components/TimerListSection.swift new file mode 100644 index 0000000..96d16b2 --- /dev/null +++ b/ios/ChronoMind/Views/Components/TimerListSection.swift @@ -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) + } + } + } +} diff --git a/ios/ChronoMind/Views/Components/UrgencyBadge.swift b/ios/ChronoMind/Views/Components/UrgencyBadge.swift new file mode 100644 index 0000000..5cffc13 --- /dev/null +++ b/ios/ChronoMind/Views/Components/UrgencyBadge.swift @@ -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) + ) + } +} diff --git a/ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift b/ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift new file mode 100644 index 0000000..95e90e5 --- /dev/null +++ b/ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift @@ -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, range: ClosedRange) -> 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 + 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)) + } +} diff --git a/ios/ChronoMind/Views/CreateTimer/QuickTimerSheet.swift b/ios/ChronoMind/Views/CreateTimer/QuickTimerSheet.swift new file mode 100644 index 0000000..0f50962 --- /dev/null +++ b/ios/ChronoMind/Views/CreateTimer/QuickTimerSheet.swift @@ -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]) + } +} diff --git a/web/src/lib/store.test.ts b/web/src/lib/store.test.ts new file mode 100644 index 0000000..22ae8d2 --- /dev/null +++ b/web/src/lib/store.test.ts @@ -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('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'); + }); + }); +});