diff --git a/ios/ChronoMind/Views/Focus/PomodoroView.swift b/ios/ChronoMind/Views/Focus/PomodoroView.swift new file mode 100644 index 0000000..7eed544 --- /dev/null +++ b/ios/ChronoMind/Views/Focus/PomodoroView.swift @@ -0,0 +1,359 @@ +// ── Pomodoro View ────────────────────────────────────────────── +// Focus session with countdown ring, round tracking, and auto-transitions + +import SwiftUI + +struct PomodoroView: View { + @EnvironmentObject var store: TimerStore + @State private var showCreatePomodoro = false + + private var activePomodoro: CMTimer? { + store.timers.first { $0.type == .pomodoro && [.active, .warning, .paused, .firing].contains($0.state) } + } + + var body: some View { + NavigationStack { + ZStack { + CMColors.bg.ignoresSafeArea() + + if let timer = activePomodoro { + ActivePomodoroView(timer: timer) + } else { + IdlePomodoroView(onStart: { showCreatePomodoro = true }) + } + } + .navigationTitle("Focus") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(CMColors.surface, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .sheet(isPresented: $showCreatePomodoro) { + PomodoroSetupSheet() + } + } + } +} + +// MARK: - Idle State + +struct IdlePomodoroView: View { + let onStart: () -> Void + @EnvironmentObject var store: TimerStore + + var body: some View { + VStack(spacing: CMSpacing.xxl) { + Spacer() + + // Decorative ring + ZStack { + Circle() + .stroke(CMColors.border, lineWidth: 12) + .frame(width: 220, height: 220) + + VStack(spacing: CMSpacing.sm) { + Image(systemName: "target") + .font(.system(size: 40)) + .foregroundStyle(CMColors.accent) + + Text("Ready to focus?") + .font(CMFonts.body(size: 18, weight: .semibold)) + .foregroundStyle(CMColors.text) + } + } + + Text("Start a Pomodoro session to stay focused\nwith structured work and break intervals") + .font(CMFonts.body(size: 14)) + .foregroundStyle(CMColors.textMuted) + .multilineTextAlignment(.center) + + // Quick start buttons + VStack(spacing: CMSpacing.md) { + Button { + HapticEngine.tap() + let _ = store.addPomodoro() + } label: { + HStack { + Image(systemName: "play.fill") + Text("Start 25/5 Session") + } + .font(CMFonts.body(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, CMSpacing.lg) + .background(CMColors.accent) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) + } + + Button { + onStart() + } label: { + HStack { + Image(systemName: "slider.horizontal.3") + Text("Custom Session") + } + .font(CMFonts.body(size: 16, weight: .medium)) + .foregroundStyle(CMColors.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, CMSpacing.lg) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: CMRadius.md) + .stroke(CMColors.accent.opacity(0.3), lineWidth: 1) + ) + } + } + .padding(.horizontal, CMSpacing.xxl) + + Spacer() + } + } +} + +// MARK: - Active Pomodoro + +struct ActivePomodoroView: View { + let timer: CMTimer + @EnvironmentObject var store: TimerStore + + private var progress: Double { + guard let duration = timer.duration, duration > 0 else { return 0 } + let remaining = getRemainingSeconds(timer, now: store.now) + return 1.0 - (remaining / duration) + } + + private var phaseLabel: String { + guard let state = timer.pomodoroState else { return "" } + if state.isLongBreak { return "Long Break" } + if state.isBreak { return "Break" } + return "Work" + } + + private var phaseIcon: String { + guard let state = timer.pomodoroState else { return "target" } + if state.isBreak || state.isLongBreak { return "cup.and.saucer.fill" } + return "brain.head.profile" + } + + var body: some View { + VStack(spacing: CMSpacing.xl) { + Spacer() + + // Phase indicator + HStack(spacing: CMSpacing.sm) { + Image(systemName: phaseIcon) + .foregroundStyle(CMColors.accent) + Text(phaseLabel) + .font(CMFonts.body(size: 16, weight: .semibold)) + .foregroundStyle(CMColors.text) + } + + // Countdown ring + CountdownRing( + progress: progress, + urgency: timer.urgency, + remainingSeconds: getRemainingSeconds(timer, now: store.now), + totalSeconds: timer.duration ?? 0 + ) + + // Timer label + Text(timer.label) + .font(CMFonts.body(size: 18, weight: .medium)) + .foregroundStyle(CMColors.textSecondary) + + // Round indicator + if let state = timer.pomodoroState, let config = timer.pomodoroConfig { + PomodoroRoundIndicator( + currentRound: state.currentRound, + totalRounds: config.rounds, + completedRounds: state.completedRounds, + isBreak: state.isBreak || state.isLongBreak + ) + } + + // Time blindness aid + let remaining = getRemainingSeconds(timer, now: store.now) + if let ref = getTimeReference(seconds: remaining) { + Text(ref) + .font(CMFonts.body(size: 13)) + .foregroundStyle(CMColors.textMuted) + .italic() + } + + Spacer() + + // Controls + HStack(spacing: CMSpacing.xxl) { + // Cancel + Button { + HapticEngine.tap() + store.dismiss(timer.id) + } label: { + VStack(spacing: CMSpacing.xs) { + Image(systemName: "xmark") + .font(.title3) + Text("Cancel") + .font(CMFonts.body(size: 12)) + } + .foregroundStyle(CMColors.textMuted) + .frame(width: 70) + } + + // Pause / Resume / Advance + if timer.state == .firing { + Button { + HapticEngine.tap() + store.advancePom(timer.id) + } label: { + VStack(spacing: CMSpacing.xs) { + Image(systemName: "forward.fill") + .font(.title) + Text("Next") + .font(CMFonts.body(size: 12)) + } + .foregroundStyle(.white) + .frame(width: 80, height: 80) + .background(CMColors.accent) + .clipShape(Circle()) + } + } else if timer.state == .paused { + Button { + HapticEngine.tap() + store.resume(timer.id) + } label: { + VStack(spacing: CMSpacing.xs) { + Image(systemName: "play.fill") + .font(.title) + Text("Resume") + .font(CMFonts.body(size: 12)) + } + .foregroundStyle(.white) + .frame(width: 80, height: 80) + .background(CMColors.accent) + .clipShape(Circle()) + } + } else { + Button { + HapticEngine.tap() + store.pause(timer.id) + } label: { + VStack(spacing: CMSpacing.xs) { + Image(systemName: "pause.fill") + .font(.title) + Text("Pause") + .font(CMFonts.body(size: 12)) + } + .foregroundStyle(.white) + .frame(width: 80, height: 80) + .background(CMColors.surface) + .clipShape(Circle()) + .overlay(Circle().stroke(CMColors.border, lineWidth: 2)) + } + } + + // Skip to break / work + Button { + HapticEngine.tap() + store.advancePom(timer.id) + } label: { + VStack(spacing: CMSpacing.xs) { + Image(systemName: "forward.end.fill") + .font(.title3) + Text("Skip") + .font(CMFonts.body(size: 12)) + } + .foregroundStyle(CMColors.textMuted) + .frame(width: 70) + } + } + .padding(.bottom, CMSpacing.xxl) + } + } +} + +// MARK: - Pomodoro Setup Sheet + +struct PomodoroSetupSheet: View { + @EnvironmentObject var store: TimerStore + @Environment(\.dismiss) private var dismiss + + @State private var label = "Focus Session" + @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) { + CMTextField(title: "Session Label", placeholder: "e.g., Deep work", text: $label) + + 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 * max(0, rounds - 1)) + longBreakMinutes + HStack { + Text("Total session time:") + .font(CMFonts.body(size: 14)) + .foregroundStyle(CMColors.textSecondary) + Spacer() + Text(formatDurationCompact(TimeInterval(totalMinutes * 60))) + .font(CMFonts.mono(size: 16, weight: .semibold)) + .foregroundStyle(CMColors.accent) + } + .padding(CMSpacing.md) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.sm)) + + // Time reference + if let ref = getTimeReference(minutes: totalMinutes) { + HStack { + Image(systemName: "lightbulb.fill") + .foregroundStyle(CMColors.standard) + .font(.caption) + Text(ref) + .font(CMFonts.body(size: 13)) + .foregroundStyle(CMColors.textMuted) + .italic() + } + } + } + .padding(CMSpacing.lg) + } + } + .navigationTitle("Pomodoro Setup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .foregroundStyle(CMColors.textSecondary) + } + ToolbarItem(placement: .confirmationAction) { + Button("Start") { + let config = PomodoroConfig( + workMinutes: workMinutes, + breakMinutes: breakMinutes, + longBreakMinutes: longBreakMinutes, + rounds: rounds + ) + let _ = store.addPomodoro(CreatePomodoroParams( + label: label, + config: config + )) + dismiss() + } + .font(.body.weight(.semibold)) + .foregroundStyle(CMColors.accent) + } + } + .toolbarBackground(CMColors.surface, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } + } +} diff --git a/ios/ChronoMind/Views/History/HistoryView.swift b/ios/ChronoMind/Views/History/HistoryView.swift new file mode 100644 index 0000000..f29ddd3 --- /dev/null +++ b/ios/ChronoMind/Views/History/HistoryView.swift @@ -0,0 +1,242 @@ +// ── History View ─────────────────────────────────────────────── +// Past timers, stats, streaks + +import SwiftUI + +struct HistoryView: View { + @EnvironmentObject var store: TimerStore + @State private var selectedSegment: HistorySegment = .recent + + enum HistorySegment: String, CaseIterable { + case recent = "Recent" + case stats = "Stats" + } + + private var completedTimers: [CMTimer] { + store.timers + .filter { [.completed, .dismissed].contains($0.state) } + .sorted { ($0.completedAt ?? $0.dismissedAt ?? .distantPast) > ($1.completedAt ?? $1.dismissedAt ?? .distantPast) } + } + + var body: some View { + NavigationStack { + ZStack { + CMColors.bg.ignoresSafeArea() + + VStack(spacing: 0) { + // Segment picker + Picker("View", selection: $selectedSegment) { + ForEach(HistorySegment.allCases, id: \.self) { segment in + Text(segment.rawValue).tag(segment) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, CMSpacing.lg) + .padding(.vertical, CMSpacing.md) + + switch selectedSegment { + case .recent: + recentList + case .stats: + statsView + } + } + } + .navigationTitle("History") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(CMColors.surface, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } + } + + // MARK: - Recent List + + private var recentList: some View { + Group { + if completedTimers.isEmpty { + VStack(spacing: CMSpacing.lg) { + Spacer() + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 48)) + .foregroundStyle(CMColors.textMuted) + Text("No timer history yet") + .font(CMFonts.body(size: 18, weight: .semibold)) + .foregroundStyle(CMColors.textSecondary) + Text("Completed and dismissed timers\nwill appear here") + .font(CMFonts.body(size: 14)) + .foregroundStyle(CMColors.textMuted) + .multilineTextAlignment(.center) + Spacer() + } + } else { + ScrollView { + LazyVStack(spacing: CMSpacing.md) { + ForEach(completedTimers) { timer in + HistoryCard(timer: timer) + } + } + .padding(.horizontal, CMSpacing.lg) + .padding(.bottom, CMSpacing.xxl) + } + } + } + } + + // MARK: - Stats View + + private var statsView: some View { + ScrollView { + VStack(spacing: CMSpacing.lg) { + // Summary cards + let allTimers = store.timers + let completed = allTimers.filter { $0.state == .completed }.count + let dismissed = allTimers.filter { $0.state == .dismissed }.count + let active = store.activeTimers.count + let totalSnoozes = allTimers.reduce(0) { $0 + $1.snoozeCount } + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: CMSpacing.md), + GridItem(.flexible(), spacing: CMSpacing.md), + ], spacing: CMSpacing.md) { + StatCard(title: "Completed", value: "\(completed)", icon: "checkmark.circle.fill", color: CMColors.success) + StatCard(title: "Dismissed", value: "\(dismissed)", icon: "xmark.circle.fill", color: CMColors.textMuted) + StatCard(title: "Active", value: "\(active)", icon: "play.circle.fill", color: CMColors.accent) + StatCard(title: "Snoozes", value: "\(totalSnoozes)", icon: "moon.zzz.fill", color: CMColors.important) + } + + // On-time rate + if completed + dismissed > 0 { + let onTimeRate = Double(completed) / Double(completed + dismissed) * 100 + VStack(alignment: .leading, spacing: CMSpacing.sm) { + HStack { + Text("Completion Rate") + .font(CMFonts.body(size: 14, weight: .medium)) + .foregroundStyle(CMColors.textSecondary) + Spacer() + Text(String(format: "%.0f%%", onTimeRate)) + .font(CMFonts.mono(size: 20, weight: .bold)) + .foregroundStyle(onTimeRate >= 70 ? CMColors.success : CMColors.important) + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(CMColors.border) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 4) + .fill(onTimeRate >= 70 ? CMColors.success : CMColors.important) + .frame(width: geo.size.width * (onTimeRate / 100), height: 8) + } + } + .frame(height: 8) + } + .padding(CMSpacing.lg) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) + } + + // Timer type breakdown + let typeBreakdown = Dictionary(grouping: allTimers, by: { $0.type }) + VStack(alignment: .leading, spacing: CMSpacing.md) { + Text("By Type") + .font(CMFonts.body(size: 14, weight: .medium)) + .foregroundStyle(CMColors.textSecondary) + + ForEach(CMTimerType.allCases) { type in + let count = typeBreakdown[type]?.count ?? 0 + if count > 0 { + HStack { + Text(type.label) + .font(CMFonts.body(size: 14)) + .foregroundStyle(CMColors.text) + Spacer() + Text("\(count)") + .font(CMFonts.mono(size: 14, weight: .semibold)) + .foregroundStyle(CMColors.accent) + } + } + } + } + .padding(CMSpacing.lg) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) + } + .padding(.horizontal, CMSpacing.lg) + .padding(.bottom, CMSpacing.xxl) + } + } +} + +// MARK: - Stat Card + +struct StatCard: View { + let title: String + let value: String + let icon: String + let color: Color + + var body: some View { + VStack(spacing: CMSpacing.sm) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(color) + + Text(value) + .font(CMFonts.mono(size: 28, weight: .bold)) + .foregroundStyle(CMColors.text) + + Text(title) + .font(CMFonts.body(size: 12, weight: .medium)) + .foregroundStyle(CMColors.textMuted) + } + .frame(maxWidth: .infinity) + .padding(CMSpacing.lg) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: CMRadius.md) + .stroke(CMColors.border, lineWidth: 1) + ) + } +} + +// MARK: - History Card + +struct HistoryCard: View { + let timer: CMTimer + + var body: some View { + HStack(spacing: CMSpacing.md) { + // Urgency dot + Circle() + .fill(CMColors.urgencyColor(timer.urgency)) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: CMSpacing.xxs) { + Text(timer.label) + .font(CMFonts.body(size: 14, weight: .medium)) + .foregroundStyle(CMColors.text) + + let endDate = timer.completedAt ?? timer.dismissedAt ?? timer.createdAt + Text(formatDateTime(endDate)) + .font(CMFonts.body(size: 12)) + .foregroundStyle(CMColors.textMuted) + } + + Spacer() + + // State badge + if timer.state == .completed { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(CMColors.success) + } else { + Image(systemName: "xmark.circle") + .foregroundStyle(CMColors.textMuted) + } + } + .padding(CMSpacing.md) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: CMRadius.sm)) + } +} diff --git a/ios/ChronoMind/Views/Settings/SettingsView.swift b/ios/ChronoMind/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..89534b9 --- /dev/null +++ b/ios/ChronoMind/Views/Settings/SettingsView.swift @@ -0,0 +1,160 @@ +// ── Settings View ────────────────────────────────────────────── +// Preferences, categories, sounds, about + +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var store: TimerStore + @EnvironmentObject var notificationManager: CMNotificationManager + + @AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard" + @AppStorage("cm_defaultCascade") private var defaultCascade = "standard" + @AppStorage("cm_hapticEnabled") private var hapticEnabled = true + @AppStorage("cm_soundEnabled") private var soundEnabled = true + + var body: some View { + NavigationStack { + ZStack { + CMColors.bg.ignoresSafeArea() + + List { + // Notifications + Section { + HStack { + Label("Notifications", systemImage: "bell.fill") + .foregroundStyle(CMColors.text) + Spacer() + if notificationManager.isAuthorized { + Text("Enabled") + .font(CMFonts.body(size: 13)) + .foregroundStyle(CMColors.success) + } else { + Button("Enable") { + Task { + await notificationManager.requestPermission() + } + } + .font(CMFonts.body(size: 13, weight: .semibold)) + .foregroundStyle(CMColors.accent) + } + } + + Toggle(isOn: $hapticEnabled) { + Label("Haptic Feedback", systemImage: "iphone.radiowaves.left.and.right") + .foregroundStyle(CMColors.text) + } + .tint(CMColors.accent) + + Toggle(isOn: $soundEnabled) { + Label("Sound", systemImage: "speaker.wave.2.fill") + .foregroundStyle(CMColors.text) + } + .tint(CMColors.accent) + } header: { + Text("Notifications & Feedback") + .foregroundStyle(CMColors.textMuted) + } + .listRowBackground(CMColors.surface) + + // Defaults + Section { + Picker(selection: $defaultUrgency) { + ForEach(UrgencyLevel.allCases) { level in + HStack { + Circle() + .fill(CMColors.urgencyColor(level)) + .frame(width: 8, height: 8) + Text(getUrgencyConfig(level).label) + } + .tag(level.rawValue) + } + } label: { + Label("Default Urgency", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(CMColors.text) + } + + Picker(selection: $defaultCascade) { + ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in + Text(preset.label).tag(preset.rawValue) + } + } label: { + Label("Default Cascade", systemImage: "arrow.down.forward.and.arrow.up.backward") + .foregroundStyle(CMColors.text) + } + } header: { + Text("Defaults") + .foregroundStyle(CMColors.textMuted) + } + .listRowBackground(CMColors.surface) + + // Data + Section { + HStack { + Label("Total Timers", systemImage: "number") + .foregroundStyle(CMColors.text) + Spacer() + Text("\(store.timers.count)") + .font(CMFonts.mono(size: 14)) + .foregroundStyle(CMColors.textSecondary) + } + + Button { + store.timers.removeAll { [.completed, .dismissed].contains($0.state) } + } label: { + Label("Clear History", systemImage: "trash") + .foregroundStyle(CMColors.error) + } + + Button { + store.timers.removeAll() + CMNotificationManager.shared.removeAllNotifications() + } label: { + Label("Delete All Timers", systemImage: "trash.fill") + .foregroundStyle(CMColors.error) + } + } header: { + Text("Data") + .foregroundStyle(CMColors.textMuted) + } + .listRowBackground(CMColors.surface) + + // About + Section { + HStack { + Label("Version", systemImage: "info.circle") + .foregroundStyle(CMColors.text) + Spacer() + Text("1.0.0 (Phase 3)") + .font(CMFonts.body(size: 13)) + .foregroundStyle(CMColors.textMuted) + } + + HStack { + Label("Product ID", systemImage: "barcode") + .foregroundStyle(CMColors.text) + Spacer() + Text("chronomind") + .font(CMFonts.mono(size: 13)) + .foregroundStyle(CMColors.textMuted) + } + + Link(destination: URL(string: "https://chronomind.app/privacy")!) { + Label("Privacy Policy", systemImage: "hand.raised.fill") + .foregroundStyle(CMColors.text) + } + } header: { + Text("About") + .foregroundStyle(CMColors.textMuted) + } + .listRowBackground(CMColors.surface) + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(CMColors.surface, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } + } +} diff --git a/web/src/lib/time-blindness.test.ts b/web/src/lib/time-blindness.test.ts new file mode 100644 index 0000000..c2babcb --- /dev/null +++ b/web/src/lib/time-blindness.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { getTimeReference, getTimeReferenceMs } from './time-blindness'; + +describe('time-blindness', () => { + describe('getTimeReference', () => { + it('returns null for 0 or negative', () => { + expect(getTimeReference(0)).toBeNull(); + expect(getTimeReference(-5)).toBeNull(); + }); + + it('returns a reference for 1 minute', () => { + expect(getTimeReference(1)).toBe('About as long as a deep breath'); + }); + + it('returns a reference for 5 minutes', () => { + expect(getTimeReference(5)).toBe('About as long as a short walk around the block'); + }); + + it('returns a reference for 25 minutes', () => { + expect(getTimeReference(25)).toBe('About as long as one Pomodoro session'); + }); + + it('returns a reference for 30 minutes', () => { + expect(getTimeReference(30)).toBe('About as long as a TV sitcom episode'); + }); + + it('returns a reference for 60 minutes', () => { + expect(getTimeReference(60)).toBe('About as long as one hour-long meeting'); + }); + + it('returns a reference for 90 minutes', () => { + expect(getTimeReference(90)).toBe('About as long as a movie'); + }); + + it('returns null for extremely large durations', () => { + expect(getTimeReference(10000)).toBeNull(); + }); + }); + + describe('getTimeReferenceMs', () => { + it('converts ms to minutes and returns reference', () => { + expect(getTimeReferenceMs(25 * 60_000)).toBe('About as long as one Pomodoro session'); + }); + + it('returns null for 0', () => { + expect(getTimeReferenceMs(0)).toBeNull(); + }); + }); +}); diff --git a/web/src/lib/urgency.test.ts b/web/src/lib/urgency.test.ts new file mode 100644 index 0000000..e8508cb --- /dev/null +++ b/web/src/lib/urgency.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { getUrgencyConfig, URGENCY_CONFIGS, URGENCY_ORDER } from './urgency'; +import type { UrgencyLevel } from './urgency'; + +describe('urgency', () => { + describe('URGENCY_ORDER', () => { + it('has 5 levels in correct priority order', () => { + expect(URGENCY_ORDER).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']); + }); + }); + + describe('URGENCY_CONFIGS', () => { + it('has config for every urgency level', () => { + for (const level of URGENCY_ORDER) { + expect(URGENCY_CONFIGS[level]).toBeDefined(); + } + }); + + it('critical has sound enabled and full-screen overlay', () => { + const critical = URGENCY_CONFIGS.critical; + expect(critical.soundEnabled).toBe(true); + expect(critical.notificationStyle).toBe('persistent'); + }); + + it('passive has sound disabled', () => { + const passive = URGENCY_CONFIGS.passive; + expect(passive.soundEnabled).toBe(false); + }); + + it('every config has required fields', () => { + for (const level of URGENCY_ORDER) { + const config = URGENCY_CONFIGS[level]; + expect(config.label).toBeTruthy(); + expect(config.color).toBeTruthy(); + expect(config.bgColor).toBeTruthy(); + expect(typeof config.soundEnabled).toBe('boolean'); + expect(config.notificationStyle).toBeTruthy(); + } + }); + }); + + describe('getUrgencyConfig', () => { + it('returns correct config for each level', () => { + const levels: UrgencyLevel[] = ['critical', 'important', 'standard', 'gentle', 'passive']; + for (const level of levels) { + const config = getUrgencyConfig(level); + expect(config).toBe(URGENCY_CONFIGS[level]); + } + }); + }); +});