feat: fix web build, add repo infra, port iOS engine modules, add routine screens

- fix(web): cast window through unknown in platform-sync.ts (TS2352)
- docs: add AGENTS.md, README.md, CLAUDE.md, .windsurfrules, .cursorrules, env.example
- feat(ios): port Recurrence.swift from web/src/lib/recurrence.ts
- feat(ios): port NLParser.swift from web/src/lib/nl-parser.ts
- feat(ios): port ContextMessages.swift from web/src/lib/context-messages.ts
- feat(ios): add CMRoutine model + Routines.swift engine with state machine + templates
- feat(ios): add RoutineListView, RoutineRunnerView, RoutineEditorView
- feat(android): add RoutineScreen.kt with list, runner, templates, step controls

Web: 373 tests passing, build succeeds with --webpack flag
This commit is contained in:
saravanakumardb1 2026-02-28 01:50:35 -08:00
parent af33a2c86d
commit 11e50295ea
15 changed files with 3072 additions and 2 deletions

17
.cursorrules Normal file
View File

@ -0,0 +1,17 @@
# ChronoMind — Cursor Rules
# Read AGENTS.md for full context.
Project: ChronoMind — AI-Powered Contextual Clock & Timer
Product ID: chronomind
Stack: Next.js 16 (web) + SwiftUI (iOS) + Jetpack Compose (Android) + Fastify 5 (backend)
Rules:
- Web engine in web/src/lib/ — pure TS, no React
- Components in web/src/components/ — React UI only
- iOS shared in ios/ChronoMind/Shared/
- Android engine in android/.../engine/ — pure Kotlin
- Theme: --cm-* CSS props (web), ChronoMindTheme (native)
- Commits: type(scope): description
- Build web with --webpack flag
- Never console.log, never hardcode colors/URLs
- Every Cosmos doc: productId: "chronomind"

37
.windsurfrules Normal file
View File

@ -0,0 +1,37 @@
# ChronoMind — Windsurf / Codeium Rules
# Read AGENTS.md for full context. These are the critical rules.
Project: ChronoMind — AI-Powered Contextual Clock & Timer
Stack: Next.js 16 (web) + SwiftUI (iOS/Watch/Mac) + Jetpack Compose (Android) + Platform-service (Fastify 5)
## Architecture Rules
- Web engine logic in web/src/lib/ — pure TS, no React imports
- Components in web/src/components/ — React UI only
- iOS shared logic in ios/ChronoMind/Shared/ — consumed by all Apple targets
- Android engine in android/app/.../engine/ — pure Kotlin, no Android framework deps
- Backend: platform-service in sibling repo learning_ai_common_plat (port 4003)
- Product ID: chronomind — every Cosmos document MUST include productId field
## Key Paths
- Web: web/src/ (Next.js 16, App Router)
- iOS: ios/ChronoMind/ (SwiftUI, iOS 17+)
- Watch: ios/ChronoMindWatch/ (watchOS 10+)
- Mac: ios/ChronoMindMac/ (macOS 14+)
- Widgets: ios/ChronoMindWidgets/ (WidgetKit)
- Android: android/app/src/main/java/com/chronomind/app/
- Wear OS: android/wear/
- Docs: docs/ (PRD.md, roadmap.md, INDUSTRY_RESEARCH.md)
- Backend modules: ../learning_ai_common_plat/services/platform-service/src/modules/{timers,routines,households,shared-timers}/
## Conventions
- Theme tokens: --cm-* CSS custom properties (web), ChronoMindTheme (native)
- Commits: feat(scope): description / fix(scope): description
- Web build: must use --webpack flag (Serwist incompatible with Turbopack)
- Never use console.log — use analytics.ts stub or req.log in Fastify
- Never hardcode colors — use theme tokens
- Never hardcode API URLs — use env vars
## Build Verification
- Web: cd web && npm test && npm run typecheck && npm run build
- iOS: Open ChronoMind.xcodeproj, Cmd+B
- Android: cd android && ./gradlew :app:compileDebugKotlin

286
AGENTS.md Normal file
View File

@ -0,0 +1,286 @@
# AGENTS.md — AI Coding Agent Instructions
> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.
> **Repo:** `learning_ai_clock` — ChronoMind AI-powered contextual clock & timer.
> **See also:** [`docs/PRD.md`](docs/PRD.md) for full product spec, [`docs/roadmap.md`](docs/roadmap.md) for implementation plan.
---
## 1. Project Identity
| Key | Value |
|-----|-------|
| **Product** | ChronoMind |
| **Product ID** | `chronomind` |
| **Bundle ID (iOS)** | `com.saravana.chronomind` |
| **Bundle ID (Android)** | `com.chronomind.app` |
| **Domain** | chronomind.app (TBD) |
| **Repo** | `learning_ai_clock` |
| **Ecosystem** | ByteLyst (shares platform-service with LysnrAI, MindLyst, NomGap) |
## 2. Repo Layout
```
learning_ai_clock/
├── web/ # Next.js 16 PWA (App Router)
│ ├── src/
│ │ ├── app/ # Pages: dashboard, focus, history, routines, settings, landing, privacy, terms
│ │ ├── components/ # React components (12 files)
│ │ │ ├── Dashboard.tsx # Main timeline + timer list
│ │ │ ├── CreateTimerModal.tsx # Timer creation with NL input
│ │ │ ├── AlarmOverlay.tsx # Full-screen critical alarm
│ │ │ ├── PomodoroView.tsx # Pomodoro session UI
│ │ │ ├── CountdownRing.tsx # Visual countdown ring
│ │ │ ├── RoutineEditor.tsx # Create/edit routines
│ │ │ ├── RoutineRunner.tsx # Execute routines step-by-step
│ │ │ ├── StatsView.tsx # Charts + analytics
│ │ │ ├── StreakCard.tsx # Streak display
│ │ │ ├── TimerCard.tsx # Individual timer card
│ │ │ ├── FocusView.tsx # Focus mode UI
│ │ │ └── Toast.tsx # In-app toast notifications
│ │ └── lib/ # Pure engine modules (16 files + 16 test files)
│ │ ├── timer-engine.ts # Timer state machine (424 lines)
│ │ ├── cascade.ts # Pre-warning cascade system
│ │ ├── urgency.ts # 5 urgency levels
│ │ ├── routines.ts # Routine engine
│ │ ├── linked-timers.ts # Timer chaining
│ │ ├── nl-parser.ts # Natural language parsing
│ │ ├── recurrence.ts # Recurring timer rules
│ │ ├── categories.ts # Timer categories/tags
│ │ ├── context-messages.ts # Contextual pre-warning messages
│ │ ├── prep-time.ts # Prep time intelligence
│ │ ├── adaptive-snooze.ts # Snooze pattern learning
│ │ ├── calendar-import.ts # .ics import
│ │ ├── stats.ts # Statistics + streaks
│ │ ├── sounds.ts # Web Audio API alarm tones
│ │ ├── notifications.ts # Web Push + Service Worker
│ │ ├── platform-sync.ts # Sync client for platform-service
│ │ ├── store.ts # Zustand + localStorage
│ │ ├── routine-store.ts # Routine Zustand store
│ │ ├── format.ts # Time formatting
│ │ ├── time-blindness.ts # Familiar time references
│ │ ├── export.ts # JSON/CSV export + import
│ │ ├── analytics.ts # Analytics stub
│ │ ├── use-sync.ts # React sync hook
│ │ ├── use-tick.ts # rAF tick hook
│ │ ├── use-theme.ts # Theme hook
│ │ ├── use-keyboard-shortcuts.ts
│ │ └── schemas.ts # Zod schemas
│ ├── e2e/ # Playwright E2E tests
│ │ └── core-flows.spec.ts # 10 test suites
│ ├── public/ # Static assets + manifest
│ ├── next.config.ts # Serwist PWA config
│ ├── package.json # Next.js 16, React 19, Zustand, Vitest
│ └── vitest.config.ts
├── ios/ # Native SwiftUI (iOS + watchOS + macOS)
│ ├── ChronoMind/ # iOS app
│ │ ├── App/ # Entry point + ContentView
│ │ ├── Shared/ # Cross-platform Swift code
│ │ │ ├── TimerEngine/ # Cascade, Format, TimerEngine, TimeBlindness, Urgency
│ │ │ ├── Store/ # TimerStore (UserDefaults-based)
│ │ │ ├── Notifications/ # UNUserNotificationCenter scheduling
│ │ │ ├── Haptics/ # UIImpactFeedbackGenerator
│ │ │ ├── Calendar/ # EventKit sync
│ │ │ ├── Cloud/ # CloudKitSyncManager + PlatformSyncManager
│ │ │ ├── Location/ # CoreLocation + MapKit travel time
│ │ │ ├── Sleep/ # HealthKit sleep integration
│ │ │ ├── Wellness/ # Mood check-in
│ │ │ ├── Gamification/ # Streaks, badges, focus score
│ │ │ ├── Growth/ # Referral manager
│ │ │ ├── Sharing/ # Shareable timer links
│ │ │ ├── Data/ # Data export manager
│ │ │ ├── Reschedule/ # AI reschedule engine
│ │ │ ├── Diagnostics/ # Crash reporter
│ │ │ ├── Accessibility/ # VoiceOver + Dynamic Type helpers
│ │ │ ├── AppGroup/ # SharedTimerData (iPhone <-> Watch <-> Widget)
│ │ │ └── Theme/ # ChronoMindTheme
│ │ ├── Views/ # SwiftUI screens + components
│ │ │ ├── Components/ # AlarmOverlay, TimerCard, CountdownRing, etc.
│ │ │ ├── CreateTimer/ # CreateTimerView, QuickTimerSheet
│ │ │ ├── Focus/ # PomodoroView
│ │ │ └── Gamification/ # BadgeGrid, ConfettiView, Streak, WeeklySummary, FocusScore
│ │ ├── LiveActivity/ # Dynamic Island + Lock Screen
│ │ └── SiriShortcuts/ # App Intents
│ ├── ChronoMindWatch/ # watchOS app (4 files)
│ ├── ChronoMindMac/ # macOS menu bar app (4 files)
│ ├── ChronoMindWidgets/ # WidgetKit (5 files)
│ ├── ChronoMindTests/ # XCTest (8 test files, 129 tests)
│ ├── ChronoMind.xcodeproj/
│ └── project.yml # XcodeGen project spec
├── android/ # Jetpack Compose + Kotlin
│ ├── app/src/main/java/com/chronomind/app/
│ │ ├── engine/ # Models.kt + TimerEngine.kt (Kotlin port)
│ │ ├── data/ # Room: TimerDatabase, TimerDao, TimerEntity, TimerMapper
│ │ ├── di/ # Hilt AppModule
│ │ ├── ui/
│ │ │ ├── screens/ # Timeline, Focus, History, Settings (4 screens)
│ │ │ ├── navigation/ # NavHost
│ │ │ └── theme/ # Material 3 theme
│ │ ├── notifications/ # AlarmManager, BroadcastReceiver, NotificationManager
│ │ ├── service/ # ForegroundService, QuickSettingsTile
│ │ ├── sync/ # PlatformApiClient, SyncRepository
│ │ ├── calendar/ # CalendarSyncManager (Google Calendar via CalendarContract)
│ │ ├── viewmodel/ # TimerViewModel
│ │ └── widget/ # Glance widgets (3 sizes)
│ ├── app/src/test/ # JUnit5 tests (30 tests)
│ └── wear/ # Wear OS app (1 file, minimal)
├── docs/
│ ├── PRD.md # Full product requirements
│ ├── roadmap.md # 5-phase implementation roadmap
│ ├── roadmap-v2-review.md # Roadmap audit trail
│ ├── INDUSTRY_RESEARCH.md # Competitive analysis
│ └── raw_idea.md # Original concept
└── .github/workflows/ # GitHub Actions CI
```
## 3. Tech Stack
| Layer | Technology |
|-------|-----------|
| **Web** | Next.js 16 (App Router), React 19, TailwindCSS v4, Zustand 5, Serwist (PWA), date-fns, Recharts, Zod, Vitest, Playwright |
| **iOS** | SwiftUI (iOS 17+), WidgetKit, ActivityKit, EventKit, CoreLocation, MapKit, HealthKit, App Intents |
| **watchOS** | SwiftUI (watchOS 10+), WidgetKit complications |
| **macOS** | SwiftUI (macOS 14+), AppKit (menu bar) |
| **Android** | Jetpack Compose, Material 3, Kotlin, Room, Hilt, Glance widgets, AlarmManager |
| **Wear OS** | Compose for Wear OS |
| **Backend** | Platform-service (Fastify 5, port 4003) in sibling repo `learning_ai_common_plat` |
| **Database** | Azure Cosmos DB via `@bytelyst/cosmos` (`productId: "chronomind"`) |
| **Tests** | Vitest (web, 373 tests), XCTest (iOS, 129 tests), JUnit5 (Android, 30 tests) |
## 4. Coding Conventions
### MUST follow
- Every Cosmos document MUST include a `productId: "chronomind"` field
- Web engine logic lives in `web/src/lib/` — pure TS, no React imports (testable without DOM)
- Components in `web/src/components/` — React UI only
- iOS shared logic in `ios/ChronoMind/Shared/` — consumed by iOS, Watch, Mac, Widgets
- Android engine in `android/app/.../engine/` — pure Kotlin, no Android framework deps
- Theme tokens use `--cm-*` CSS custom properties (web) or `ChronoMindTheme` (native)
- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
### MUST NOT do
- Never use `console.log` in production code
- Never hardcode colors — use theme tokens
- Never hardcode API URLs — use env vars or config
- Never modify tests to make them pass — fix the actual code
- Never delete existing comments or documentation unless explicitly asked
- Never add emojis to code unless explicitly asked
## 5. Key Design Decisions
### Pre-Warning Cascade (Core Feature)
- 6 presets: Aggressive, Standard, Light, Minimal, None, Custom
- Each timer has cascade intervals (e.g., [120, 60, 30, 15, 5] minutes)
- Cascade fires notifications at each interval before target time
### Urgency Levels
- **Critical** — persistent sound, full-screen overlay, no auto-dismiss
- **Important** — prominent notification, medium sound
- **Standard** — normal notification
- **Gentle** — subtle notification, soft sound
- **Passive** — badge only, no sound
### Timer Types
- **Alarm** — fires at specific time
- **Countdown** — fires after duration
- **Pomodoro** — work/break cycles
- **Event** — days until future date with milestone warnings
## 6. Build & Test Commands
```bash
# ── Web ────────────────────────────────────────────
cd web && npm run dev # Dev server (port 3000)
cd web && npm run build # Production build (uses --webpack flag)
cd web && npm test # Vitest (373 tests)
cd web && npm run typecheck # tsc --noEmit
cd web && npm run test:e2e # Playwright E2E
# ── iOS (requires Xcode) ──────────────────────────
# Open ios/ChronoMind.xcodeproj in Xcode
# Cmd+B to build, Cmd+U to test (129 XCTests)
# ── Android (requires Android SDK) ────────────────
cd android && ./gradlew :app:compileDebugKotlin
cd android && ./gradlew :app:test # 30 JUnit5 tests
# ── Full Verification ─────────────────────────────
cd web && npm test && npm run typecheck && npm run build
```
## 7. Backend Integration
ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_common_plat`. Four ChronoMind-specific modules exist:
| Module | Container | Endpoints | Tests |
|--------|-----------|-----------|-------|
| `timers` | `timers` (pk: `/userId`) | 7 (CRUD + delta sync + batch) | 42 |
| `routines` | `routines` (pk: `/userId`) | 7 (CRUD + delta sync + batch) | 32 |
| `households` | `households` (pk: `/id`) | 9 (CRUD + invite/join/leave) | 26 |
| `shared-timers` | `shared_timers` (pk: `/householdId`) | 6 (CRUD + per-user ack) | 30 |
All documents include `productId: "chronomind"`.
### Sync Protocol
- `syncVersion` monotonic integer — optimistic concurrency, 409 on stale writes
- Delta sync: `GET /timers/sync?since=<ISO>` returns only changed timers
- Batch upsert: `POST /timers/batch` for offline queue flush
### Sync Clients (written, not yet wired into app flow)
- Web: `web/src/lib/platform-sync.ts` + `use-sync.ts`
- iOS: `ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift`
- Android: `android/.../sync/PlatformApiClient.kt` + `SyncRepository.kt`
## 8. Color Palette
| Token | Hex | Use |
|-------|-----|-----|
| `--cm-bg-canvas` | `#0A0B0F` | Page background |
| `--cm-bg-elevated` | `#12131A` | Elevated surfaces |
| `--cm-surface-card` | `#1A1B26` | Cards |
| `--cm-text-primary` | `#E8ECF4` | Main text |
| `--cm-text-secondary` | `#8B92A8` | Descriptions |
| `--cm-text-tertiary` | `#565C72` | Timestamps |
| `--cm-accent` | `#5B8DEE` | Primary accent |
| Critical urgency | `#FF4757` | Red |
| Important urgency | `#FF9F43` | Orange |
| Standard urgency | `#FECA57` | Yellow |
| Gentle urgency | `#2ED573` | Green |
| Passive urgency | `#8B92A8` | Gray |
## 9. Known Gaps (as of Feb 2026)
### iOS — Missing Swift Ports
- `Recurrence.swift` — no recurring timer engine on iOS
- `NLParser.swift` — no natural language parser on iOS
- `ContextMessages.swift` — no contextual pre-warning messages on iOS
- Routine models + UI (`RoutineListView`, `RoutineRunnerView`) not built
- Still using UserDefaults — SwiftData migration pending
### Android
- `RoutineScreen.kt` not built
- Wear OS is minimal (1 file)
### Cross-Platform
- Sync clients exist but not wired into app flow
- No account creation (Apple/Google Sign-In)
- No iCloud/CloudKit sync wired
### Infrastructure
- No domain registered
- No Vercel deployment
- No TestFlight / App Store / Play Store submissions
## 10. Common Pitfalls
1. **Next.js 16 + Turbopack** — Must pass `--webpack` flag to `next build` and `next dev` (Serwist not yet compatible with Turbopack)
2. **Web engine purity**`web/src/lib/` files must NOT import from React or Next.js
3. **iOS targets** — 4 targets share `Shared/` folder: ChronoMind (iOS), ChronoMindWatch, ChronoMindMac, ChronoMindWidgets
4. **Android Hilt** — All new `@Singleton` classes must be provided in `AppModule.kt`
5. **Sync version** — All timer mutations must increment `syncVersion` for conflict detection

24
CLAUDE.md Normal file
View File

@ -0,0 +1,24 @@
# ChronoMind — Claude Code Instructions
Read AGENTS.md for full context. Key rules:
## Quick Reference
- Product: ChronoMind (productId: "chronomind")
- Web: Next.js 16 + React 19 + TailwindCSS v4 + Zustand 5
- iOS: SwiftUI (iOS 17+), watchOS 10+, macOS 14+
- Android: Jetpack Compose + Material 3 + Room + Hilt
- Backend: platform-service (Fastify 5) in ../learning_ai_common_plat
- Tests: Vitest (web), XCTest (iOS), JUnit5 (Android)
## Build Commands
```bash
cd web && npm test && npm run typecheck && npm run build
```
## Critical Rules
- Engine logic (web/src/lib/) must be pure TS — no React imports
- iOS shared logic in ios/ChronoMind/Shared/ — shared across all Apple targets
- Android engine in engine/ package — pure Kotlin
- Every Cosmos doc needs productId: "chronomind"
- Web build requires --webpack flag (Serwist + Turbopack incompatible)
- Commits: type(scope): description

84
README.md Normal file
View File

@ -0,0 +1,84 @@
# ChronoMind
> AI-powered contextual clock & timer — never be caught off-guard again.
**ChronoMind** is a cross-platform time awareness layer that understands *why* you set a timer, not just *when*. It provides intelligent pre-warning cascades, urgency-based escalation, routine orchestration, and natural language interaction.
## Platforms
| Platform | Stack | Status |
|----------|-------|--------|
| **Web PWA** | Next.js 16, React 19, TailwindCSS v4, Zustand | ✅ 373 tests passing |
| **iOS** | SwiftUI (iOS 17+), WidgetKit, ActivityKit | ✅ 129 XCTests |
| **Apple Watch** | SwiftUI (watchOS 10+), Complications | ✅ Built |
| **macOS** | SwiftUI menu bar app (macOS 14+) | ✅ Built |
| **Android** | Jetpack Compose, Material 3, Room, Hilt | ✅ 30 JUnit5 tests |
| **Wear OS** | Compose for Wear OS | 🚧 Minimal |
| **Backend** | Platform-service (Fastify 5, port 4003) | ✅ 130 tests (4 modules) |
## Core Features
- **Pre-warning cascades** — configurable warnings before every timer (2h → 1h → 30m → 15m → 5m → NOW)
- **5 urgency levels** — Critical, Important, Standard, Gentle, Passive — each with distinct notification style
- **Routines** — multi-step sequences (Morning, Workout, Cooking, Wind-Down) with transitions
- **Natural language** — "meeting in 30 min", "alarm at 3pm", "pomodoro 4 rounds"
- **Focus mode** — Pomodoro sessions with notification blocking
- **Linked timers** — "When timer A ends, start timer B"
- **Visual timeline** — color-coded, urgency-aware dashboard
- **Stats & streaks** — daily activity, on-time rate, focus hours, streak tracking
- **Calendar import** — .ics file import with conflict detection
- **AI reschedule** — "I slept in 30 minutes" → shift all morning timers
- **Shareable timers** — share timer links across platforms
- **Household shared timers** — family/team coordination (up to 6 members)
## Quick Start
```bash
# Web (development)
cd web && npm install && npm run dev
# Run tests
cd web && npm test # 373 Vitest tests
cd web && npm run typecheck # TypeScript check
cd web && npm run build # Production build
# iOS — open ios/ChronoMind.xcodeproj in Xcode
# Android — open android/ in Android Studio
```
## Project Structure
```
learning_ai_clock/
├── web/ # Next.js 16 PWA (12,960 lines, 58 files)
├── ios/ # SwiftUI iOS + Watch + Mac + Widgets (12,946 lines, 72 files)
├── android/ # Jetpack Compose + Wear OS (3,427 lines, 25 files)
├── docs/ # PRD, roadmap, research
└── .github/ # CI workflows
```
## Backend
Uses the shared **platform-service** from [`learning_ai_common_plat`](../learning_ai_common_plat) with 4 ChronoMind-specific modules:
- `timers` — CRUD + delta sync + batch upsert (42 tests)
- `routines` — CRUD + delta sync + batch upsert (32 tests)
- `households` — group management + invitations (26 tests)
- `shared-timers` — household-scoped timers (30 tests)
All documents use `productId: "chronomind"`.
## Ecosystem
Part of the **ByteLyst** ecosystem alongside [LysnrAI](../learning_voice_ai_agent), [MindLyst](../learning_multimodal_memory_agents), and [NomGap](../learning_ai_fastgap).
## Docs
- [PRD](docs/PRD.md) — Full product requirements
- [Roadmap](docs/roadmap.md) — 5-phase implementation plan
- [Industry Research](docs/INDUSTRY_RESEARCH.md) — Competitive analysis
- [AGENTS.md](AGENTS.md) — AI coding agent instructions
## License
Closed-source application. Timer engine library open-source (license TBD).

View File

@ -0,0 +1,549 @@
package com.chronomind.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.chronomind.app.ui.theme.CMColors
import java.util.UUID
// ── Routine Models (pure Kotlin, no Android deps) ─────────────
enum class TransitionType(val label: String, val minutes: Int) {
IMMEDIATE("Immediate", 0),
ONE_MIN_BREAK("1 min break", 1),
FIVE_MIN_BREAK("5 min break", 5),
CUSTOM("Custom", 0)
}
enum class RoutineStatus { TEMPLATE, READY, ACTIVE, PAUSED, COMPLETED, CANCELLED }
enum class StepStatus { PENDING, ACTIVE, SKIPPED, COMPLETED }
data class RoutineStep(
val id: String = UUID.randomUUID().toString(),
val label: String,
val durationMinutes: Int,
val transition: TransitionType = TransitionType.IMMEDIATE,
val notes: String? = null,
var status: StepStatus = StepStatus.PENDING,
var startedAt: Long? = null,
var completedAt: Long? = null
)
data class Routine(
val id: String = UUID.randomUUID().toString(),
val name: String,
val description: String? = null,
val steps: List<RoutineStep>,
val isTemplate: Boolean = false,
var status: RoutineStatus = if (isTemplate) RoutineStatus.TEMPLATE else RoutineStatus.READY,
var currentStepIndex: Int = 0,
var startedAt: Long? = null,
var pausedAt: Long? = null,
var completedAt: Long? = null,
var elapsedBeforePauseMs: Long = 0
) {
val totalDurationMinutes: Int
get() = steps.mapIndexed { idx, step ->
step.durationMinutes + if (idx < steps.size - 1) step.transition.minutes else 0
}.sum()
val progress: Float
get() {
if (steps.isEmpty()) return 0f
val done = steps.count { it.status == StepStatus.COMPLETED || it.status == StepStatus.SKIPPED }
return done.toFloat() / steps.size
}
val currentStep: RoutineStep? get() = steps.getOrNull(currentStepIndex)
val nextStep: RoutineStep? get() = steps.getOrNull(currentStepIndex + 1)
}
// ── Built-in Templates ────────────────────────────────────────
object RoutineTemplates {
val morning = Routine(
name = "Morning Routine",
description = "Start your day with intention",
isTemplate = true,
steps = listOf(
RoutineStep(label = "Wake Up + Hydrate", durationMinutes = 5, notes = "Drink a glass of water"),
RoutineStep(label = "Meditation", durationMinutes = 15, transition = TransitionType.ONE_MIN_BREAK, notes = "Mindfulness or breathing"),
RoutineStep(label = "Exercise", durationMinutes = 30, transition = TransitionType.ONE_MIN_BREAK, notes = "Workout, yoga, or a walk"),
RoutineStep(label = "Shower + Get Ready", durationMinutes = 20),
RoutineStep(label = "Breakfast", durationMinutes = 15),
)
)
val workout = Routine(
name = "Workout",
description = "Structured workout with warm-up and cool-down",
isTemplate = true,
steps = listOf(
RoutineStep(label = "Warm Up", durationMinutes = 5, notes = "Light stretching"),
RoutineStep(label = "Main Workout", durationMinutes = 30, transition = TransitionType.ONE_MIN_BREAK, notes = "Strength or cardio"),
RoutineStep(label = "Cool Down", durationMinutes = 5, notes = "Stretching"),
RoutineStep(label = "Hydrate + Recovery", durationMinutes = 5),
)
)
val cookingPrep = Routine(
name = "Cooking Prep",
description = "Organized meal preparation",
isTemplate = true,
steps = listOf(
RoutineStep(label = "Gather Ingredients", durationMinutes = 5, notes = "Get everything out"),
RoutineStep(label = "Prep & Chop", durationMinutes = 15, notes = "Wash, peel, and chop"),
RoutineStep(label = "Cook", durationMinutes = 25, transition = TransitionType.FIVE_MIN_BREAK, notes = "Follow the recipe"),
RoutineStep(label = "Plate & Serve", durationMinutes = 5),
)
)
val eveningWindDown = Routine(
name = "Evening Wind-Down",
description = "Prepare your mind and body for rest",
isTemplate = true,
steps = listOf(
RoutineStep(label = "Screen-Free Time", durationMinutes = 15, notes = "Put devices away"),
RoutineStep(label = "Light Stretching", durationMinutes = 10, transition = TransitionType.ONE_MIN_BREAK),
RoutineStep(label = "Journal / Reflect", durationMinutes = 10, notes = "Write about your day"),
RoutineStep(label = "Read", durationMinutes = 20, notes = "Read a book"),
)
)
val all = listOf(morning, workout, cookingPrep, eveningWindDown)
}
// ── Routine Screen ────────────────────────────────────────────
@Composable
fun RoutineScreen() {
var routines by remember { mutableStateOf(RoutineTemplates.all) }
var activeRoutine by remember { mutableStateOf<Routine?>(null) }
if (activeRoutine != null) {
RoutineRunnerView(
routine = activeRoutine!!,
onUpdate = { activeRoutine = it },
onComplete = { activeRoutine = null },
onCancel = { activeRoutine = null }
)
} else {
RoutineListView(
routines = routines,
onStart = { routine ->
val instance = if (routine.isTemplate) {
routine.copy(
id = UUID.randomUUID().toString(),
isTemplate = false,
status = RoutineStatus.READY,
steps = routine.steps.map { it.copy(id = UUID.randomUUID().toString(), status = StepStatus.PENDING) }
)
} else routine
// Start it
val now = System.currentTimeMillis()
val started = instance.copy(
status = RoutineStatus.ACTIVE,
startedAt = now,
currentStepIndex = 0,
steps = instance.steps.mapIndexed { idx, step ->
if (idx == 0) step.copy(status = StepStatus.ACTIVE, startedAt = now)
else step.copy(status = StepStatus.PENDING)
}
)
activeRoutine = started
}
)
}
}
// ── Routine List ──────────────────────────────────────────────
@Composable
private fun RoutineListView(
routines: List<Routine>,
onStart: (Routine) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(CMColors.bg)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
"Routines",
color = CMColors.textPrimary,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text("Templates", color = CMColors.textSecondary, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(8.dp))
}
items(routines) { routine ->
RoutineCard(routine = routine, onStart = onStart)
}
}
}
@Composable
private fun RoutineCard(routine: Routine, onStart: (Routine) -> Unit) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CMColors.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(routine.name, color = CMColors.textPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold)
routine.description?.let {
Text(it, color = CMColors.textSecondary, fontSize = 14.sp)
}
}
Column(horizontalAlignment = Alignment.End) {
Text("${routine.totalDurationMinutes} min", color = CMColors.accent, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text("${routine.steps.size} steps", color = CMColors.textTertiary, fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Step chips
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(routine.steps.take(4)) { step ->
Surface(
shape = RoundedCornerShape(4.dp),
color = CMColors.surfaceHover
) {
Text(
step.label,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = CMColors.textSecondary,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (routine.steps.size > 4) {
item {
Text("+${routine.steps.size - 4}", color = CMColors.textTertiary, fontSize = 11.sp)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { onStart(routine) },
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Start", modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Start", fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
}
}
// ── Routine Runner ────────────────────────────────────────────
@Composable
private fun RoutineRunnerView(
routine: Routine,
onUpdate: (Routine) -> Unit,
onComplete: () -> Unit,
onCancel: () -> Unit
) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(500L)
now = System.currentTimeMillis()
// Auto-advance if step time elapsed
if (routine.status == RoutineStatus.ACTIVE) {
val step = routine.currentStep ?: continue
val start = step.startedAt ?: continue
val elapsed = routine.elapsedBeforePauseMs + (now - start)
val totalMs = step.durationMinutes * 60_000L
if (elapsed >= totalMs) {
val completed = completeStep(routine, now)
onUpdate(completed)
if (completed.status == RoutineStatus.COMPLETED) {
// Stay on completed view briefly
}
}
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(CMColors.bg)
.padding(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
onCancel()
}) {
Icon(Icons.Default.Close, contentDescription = "Cancel", tint = CMColors.textSecondary)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(routine.name, color = CMColors.textPrimary, fontWeight = FontWeight.Bold)
Text(
"Step ${minOf(routine.currentStepIndex + 1, routine.steps.size)} of ${routine.steps.size}",
color = CMColors.textSecondary, fontSize = 12.sp
)
}
Spacer(modifier = Modifier.width(48.dp))
}
// Progress bar
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { routine.progress },
modifier = Modifier.fillMaxWidth().height(4.dp),
color = CMColors.accent,
trackColor = CMColors.surfaceHover
)
if (routine.status == RoutineStatus.COMPLETED) {
// Completed view
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = CMColors.success, modifier = Modifier.size(80.dp))
Spacer(modifier = Modifier.height(16.dp))
Text("Routine Complete!", color = CMColors.textPrimary, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(16.dp))
val completed = routine.steps.count { it.status == StepStatus.COMPLETED }
val skipped = routine.steps.count { it.status == StepStatus.SKIPPED }
Text("$completed completed, $skipped skipped", color = CMColors.textSecondary)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onComplete,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
shape = RoundedCornerShape(12.dp)
) {
Text("Done", fontWeight = FontWeight.Bold, modifier = Modifier.padding(8.dp))
}
} else {
// Active step display
val step = routine.currentStep
if (step != null) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(step.label, color = CMColors.textPrimary, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(16.dp))
// Remaining time
val remainingMs = if (routine.status == RoutineStatus.PAUSED) {
step.durationMinutes * 60_000L - routine.elapsedBeforePauseMs
} else {
val start = step.startedAt ?: now
val elapsed = routine.elapsedBeforePauseMs + (now - start)
maxOf(0L, step.durationMinutes * 60_000L - elapsed)
}
val remainingSec = (remainingMs / 1000).toInt()
val mm = remainingSec / 60
val ss = remainingSec % 60
Text(
String.format("%d:%02d", mm, ss),
color = if (remainingSec <= 30) CMColors.critical else CMColors.accent,
fontSize = 56.sp,
fontWeight = FontWeight.Thin,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
step.notes?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(it, color = CMColors.textSecondary, fontSize = 14.sp)
}
routine.nextStep?.let { next ->
Spacer(modifier = Modifier.height(16.dp))
Text("Next: ${next.label}", color = CMColors.textTertiary, fontSize = 13.sp)
}
}
Spacer(modifier = Modifier.weight(1f))
}
// Step pills
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
itemsIndexed(routine.steps) { idx, s ->
val isCurrent = idx == routine.currentStepIndex && routine.status == RoutineStatus.ACTIVE
Surface(
shape = RoundedCornerShape(16.dp),
color = if (isCurrent) CMColors.accent.copy(alpha = 0.2f) else CMColors.surfaceHover,
border = if (isCurrent) androidx.compose.foundation.BorderStroke(1.dp, CMColors.accent) else null
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
val icon = when (s.status) {
StepStatus.COMPLETED -> Icons.Default.CheckCircle
StepStatus.SKIPPED -> Icons.Default.SkipNext
StepStatus.ACTIVE -> Icons.Default.PlayCircle
StepStatus.PENDING -> Icons.Default.RadioButtonUnchecked
}
val tint = when (s.status) {
StepStatus.COMPLETED -> CMColors.success
StepStatus.SKIPPED -> CMColors.warning
StepStatus.ACTIVE -> CMColors.accent
StepStatus.PENDING -> CMColors.textTertiary
}
Icon(icon, contentDescription = null, modifier = Modifier.size(12.dp), tint = tint)
Text(s.label, fontSize = 11.sp, color = if (isCurrent) CMColors.accent else tint, maxLines = 1)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (routine.status == RoutineStatus.ACTIVE) {
// Skip
IconButton(onClick = {
onUpdate(skipStep(routine, now))
}) {
Icon(Icons.Default.SkipNext, contentDescription = "Skip", tint = CMColors.textSecondary, modifier = Modifier.size(32.dp))
}
Spacer(modifier = Modifier.width(16.dp))
// Complete step
Button(
onClick = { onUpdate(completeStep(routine, now)) },
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
contentPadding = PaddingValues(20.dp)
) {
Icon(Icons.Default.Check, contentDescription = "Complete Step", modifier = Modifier.size(32.dp))
}
Spacer(modifier = Modifier.width(16.dp))
// Pause
IconButton(onClick = {
val paused = routine.copy(
status = RoutineStatus.PAUSED,
pausedAt = now,
elapsedBeforePauseMs = routine.elapsedBeforePauseMs +
(now - (routine.currentStep?.startedAt ?: now))
)
onUpdate(paused)
}) {
Icon(Icons.Default.Pause, contentDescription = "Pause", tint = CMColors.textSecondary, modifier = Modifier.size(32.dp))
}
} else if (routine.status == RoutineStatus.PAUSED) {
Button(
onClick = {
val resumed = routine.copy(
status = RoutineStatus.ACTIVE,
pausedAt = null,
steps = routine.steps.mapIndexed { idx, s ->
if (idx == routine.currentStepIndex) s.copy(startedAt = now)
else s
}
)
onUpdate(resumed)
},
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
contentPadding = PaddingValues(20.dp)
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Resume", modifier = Modifier.size(32.dp))
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
// ── State Helpers ─────────────────────────────────────────────
private fun completeStep(routine: Routine, now: Long): Routine {
val idx = routine.currentStepIndex
if (idx >= routine.steps.size) return routine
val updatedSteps = routine.steps.mapIndexed { i, s ->
if (i == idx) s.copy(status = StepStatus.COMPLETED, completedAt = now)
else s
}
val nextIdx = idx + 1
return if (nextIdx >= routine.steps.size) {
routine.copy(steps = updatedSteps, status = RoutineStatus.COMPLETED, completedAt = now, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
} else {
val advanced = updatedSteps.mapIndexed { i, s ->
if (i == nextIdx) s.copy(status = StepStatus.ACTIVE, startedAt = now) else s
}
routine.copy(steps = advanced, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
}
}
private fun skipStep(routine: Routine, now: Long): Routine {
val idx = routine.currentStepIndex
if (idx >= routine.steps.size) return routine
val updatedSteps = routine.steps.mapIndexed { i, s ->
if (i == idx) s.copy(status = StepStatus.SKIPPED, completedAt = now)
else s
}
val nextIdx = idx + 1
return if (nextIdx >= routine.steps.size) {
routine.copy(steps = updatedSteps, status = RoutineStatus.COMPLETED, completedAt = now, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
} else {
val advanced = updatedSteps.mapIndexed { i, s ->
if (i == nextIdx) s.copy(status = StepStatus.ACTIVE, startedAt = now) else s
}
routine.copy(steps = advanced, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
}
}

View File

@ -0,0 +1,176 @@
// Contextual Pre-Warning Messages
// Keyword helpful prep message mapping. Expandable, no LLM needed.
// Used in notification body and on timeline to give actionable context.
// Ported from web/src/lib/context-messages.ts
import Foundation
// MARK: - Context Rule
struct ContextRule {
let keywords: [String]
let messages: [String]
}
// MARK: - Rules Engine
private let contextRules: [ContextRule] = [
// Meetings & calls
ContextRule(
keywords: ["meeting", "standup", "sync", "huddle", "scrum", "1:1", "one-on-one"],
messages: ["Review your agenda", "Check meeting notes", "Prepare talking points"]
),
ContextRule(
keywords: ["call", "phone", "dial"],
messages: ["Have the number ready", "Review call notes", "Find a quiet spot"]
),
ContextRule(
keywords: ["interview", "screening"],
messages: ["Review the job description", "Prepare your questions", "Test your camera and mic"]
),
ContextRule(
keywords: ["presentation", "demo", "pitch"],
messages: ["Run through your slides", "Check your screen sharing", "Have backup ready"]
),
// Travel & transport
ContextRule(
keywords: ["flight", "plane", "airport"],
messages: ["Check in online", "Verify gate number", "Pack your carry-on"]
),
ContextRule(
keywords: ["train", "bus", "subway", "metro"],
messages: ["Check for delays", "Have your ticket ready"]
),
ContextRule(
keywords: ["drive", "commute", "carpool", "uber", "lyft", "taxi", "cab"],
messages: ["Check traffic conditions", "Grab your keys"]
),
ContextRule(
keywords: ["pickup", "pick up", "drop off", "dropoff"],
messages: ["Confirm the location", "Check for any updates"]
),
// Health & wellness
ContextRule(
keywords: ["doctor", "dentist", "therapist", "appointment", "clinic", "hospital"],
messages: ["Bring your insurance card", "Leave with travel buffer", "Note any symptoms to mention"]
),
ContextRule(
keywords: ["medicine", "medication", "pill", "vitamin"],
messages: ["Take with water", "Check dosage"]
),
ContextRule(
keywords: ["workout", "exercise", "gym", "run", "yoga", "stretch"],
messages: ["Hydrate beforehand", "Change into workout clothes", "Warm up first"]
),
// Food & cooking
ContextRule(
keywords: ["cook", "cooking", "bake", "baking", "oven", "recipe"],
messages: ["Preheat the oven", "Gather your ingredients", "Check you have everything"]
),
ContextRule(
keywords: ["pasta", "noodle", "rice", "boil"],
messages: ["Start boiling water", "Salt the water"]
),
ContextRule(
keywords: ["dinner", "lunch", "breakfast", "meal", "eat"],
messages: ["Start prepping ingredients", "Set the table"]
),
ContextRule(
keywords: ["laundry", "washer", "dryer", "clothes"],
messages: ["Move clothes to dryer", "Check pockets first"]
),
// Work & productivity
ContextRule(
keywords: ["deadline", "due", "submit", "submission"],
messages: ["Final review before submitting", "Double-check requirements"]
),
ContextRule(
keywords: ["class", "lecture", "lesson", "school", "study"],
messages: ["Pack your materials", "Review last session notes"]
),
ContextRule(
keywords: ["focus", "deep work", "concentrate"],
messages: ["Close unnecessary tabs", "Put phone on silent", "Grab water"]
),
// Personal
ContextRule(
keywords: ["birthday", "anniversary", "celebration", "party"],
messages: ["Check if the gift is ready", "Confirm the plan"]
),
ContextRule(
keywords: ["walk", "dog", "pet"],
messages: ["Grab the leash", "Bring waste bags"]
),
ContextRule(
keywords: ["sleep", "bed", "bedtime", "wind down", "rest"],
messages: ["Start winding down", "Put away screens", "Set tomorrow's alarm"]
),
ContextRule(
keywords: ["wake", "morning", "alarm"],
messages: ["Time to get up!", "Stretch and hydrate"]
),
]
// MARK: - Lookup
/// Get a contextual message for a timer label.
/// Returns the first matching message, or nil if no match.
func getContextMessage(label: String) -> String? {
let lower = label.lowercased()
for rule in contextRules {
if rule.keywords.contains(where: { lower.contains($0) }) {
return rule.messages.first
}
}
return nil
}
/// Get all matching contextual messages for a timer label.
/// Returns an array of messages from all matching rules.
func getAllContextMessages(label: String) -> [String] {
let lower = label.lowercased()
var messages: [String] = []
for rule in contextRules {
if rule.keywords.contains(where: { lower.contains($0) }) {
if let first = rule.messages.first {
messages.append(first)
}
}
}
return messages
}
/// Get a contextual message formatted for a pre-warning notification.
/// Includes the time remaining context.
func getWarningMessage(label: String, minutesBefore: Int) -> String {
let contextMsg = getContextMessage(label: label)
let timeStr: String
if minutesBefore >= 60 {
let hours = minutesBefore / 60
let mins = minutesBefore % 60
timeStr = mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h"
} else {
timeStr = "\(minutesBefore)m"
}
let base = "\(label) in \(timeStr)"
if let msg = contextMsg {
return "\(base)\(msg)"
}
return base
}
/// Check if a label matches any context rule.
func hasContextMatch(label: String) -> Bool {
return getContextMessage(label: label) != nil
}
/// Get all registered context rules (for UI display / editing).
func getContextRules() -> [ContextRule] {
return contextRules
}

View File

@ -0,0 +1,479 @@
// Natural Language Timer Parser
// Regex-based parser for natural time expressions. No LLM needed.
// Supports: relative times, absolute times, durations, labels, urgency hints, pomodoro.
// Ported from web/src/lib/nl-parser.ts
import Foundation
// MARK: - Parsed Timer Types
enum ParsedTimerType: String {
case alarm
case countdown
case pomodoro
}
struct ParsedTimer {
let type: ParsedTimerType
let label: String
let durationSeconds: TimeInterval? // for countdown
let targetTime: Date? // for alarm
let urgency: UrgencyLevel
let cascade: CascadePreset
let pomodoroRounds: Int? // for pomodoro
let confidence: Double // 0-1
let raw: String
}
struct ParseResult {
let success: Bool
let timer: ParsedTimer?
let error: String?
}
// MARK: - Urgency Keywords
private let urgencyKeywords: [(String, UrgencyLevel)] = [
// Critical
("critical", .critical),
("urgent", .critical),
("emergency", .critical),
("flight", .critical),
("interview", .critical),
("exam", .critical),
// Important
("important", .important),
("meeting", .important),
("appointment", .important),
("doctor", .important),
("dentist", .important),
("standup", .important),
("call", .important),
// Gentle
("gentle", .gentle),
("casual", .gentle),
("maybe", .gentle),
("check", .gentle),
// Passive
("passive", .passive),
]
// MARK: - Duration Extraction
private struct DurationPattern {
let pattern: NSRegularExpression
let extractSeconds: ([String]) -> TimeInterval
}
private let durationPatterns: [DurationPattern] = {
func regex(_ pattern: String) -> NSRegularExpression {
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
return [
// "1h 30m", "1 hour 30 minutes"
DurationPattern(
pattern: regex(#"(\d+)\s*(?:hours?|hrs?|h)\s*(\d+)\s*(?:minutes?|mins?|m\b)"#),
extractSeconds: { groups in
(Double(groups[1]) ?? 0) * 3600 + (Double(groups[2]) ?? 0) * 60
}
),
// "30 minutes", "30m", "30 min"
DurationPattern(
pattern: regex(#"(\d+)\s*(?:minutes?|mins?|m\b)"#),
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 60 }
),
// "2 hours", "2h"
DurationPattern(
pattern: regex(#"(\d+)\s*(?:hours?|hrs?|h\b)"#),
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 }
),
// "30 seconds", "30s"
DurationPattern(
pattern: regex(#"(\d+)\s*(?:seconds?|secs?|s\b)"#),
extractSeconds: { groups in Double(groups[1]) ?? 0 }
),
// "half hour"
DurationPattern(
pattern: regex(#"half\s+(?:an?\s+)?hour"#),
extractSeconds: { _ in 30 * 60 }
),
// "quarter hour"
DurationPattern(
pattern: regex(#"quarter\s+(?:of\s+)?(?:an?\s+)?hour"#),
extractSeconds: { _ in 15 * 60 }
),
]
}()
// MARK: - Time Extraction (absolute)
private struct TimePattern {
let pattern: NSRegularExpression
let extractTime: ([String]) -> (hours: Int, minutes: Int)?
}
private let timePatterns: [TimePattern] = {
func regex(_ pattern: String) -> NSRegularExpression {
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
return [
// "3:30pm", "3:30 PM"
TimePattern(
pattern: regex(#"(\d{1,2}):(\d{2})\s*(am|pm)"#),
extractTime: { groups in
var hours = Int(groups[1]) ?? 0
let minutes = Int(groups[2]) ?? 0
let ampm = groups[3].lowercased()
if ampm == "pm" && hours != 12 { hours += 12 }
if ampm == "am" && hours == 12 { hours = 0 }
return (hours, minutes)
}
),
// "3pm", "3 pm"
TimePattern(
pattern: regex(#"(\d{1,2})\s*(am|pm)"#),
extractTime: { groups in
var hours = Int(groups[1]) ?? 0
let ampm = groups[2].lowercased()
if ampm == "pm" && hours != 12 { hours += 12 }
if ampm == "am" && hours == 12 { hours = 0 }
return (hours, 0)
}
),
// "15:30" (24-hour)
TimePattern(
pattern: regex(#"(\d{1,2}):(\d{2})(?!\s*(?:am|pm))"#),
extractTime: { groups in
let hours = Int(groups[1]) ?? 0
let minutes = Int(groups[2]) ?? 0
if hours > 23 || minutes > 59 { return nil }
return (hours, minutes)
}
),
]
}()
// MARK: - Relative Time Patterns
private let relativePatterns: [DurationPattern] = {
func regex(_ pattern: String) -> NSRegularExpression {
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
return [
DurationPattern(
pattern: regex(#"in\s+(\d+)\s*(?:hours?|hrs?|h)\s+(?:and\s+)?(\d+)\s*(?:minutes?|mins?|m\b)"#),
extractSeconds: { groups in
(Double(groups[1]) ?? 0) * 3600 + (Double(groups[2]) ?? 0) * 60
}
),
DurationPattern(
pattern: regex(#"in\s+(\d+)\s*(?:minutes?|mins?|m\b)"#),
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 60 }
),
DurationPattern(
pattern: regex(#"in\s+(\d+)\s*(?:hours?|hrs?|h\b)"#),
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 }
),
DurationPattern(
pattern: regex(#"in\s+(\d+)\s*(?:seconds?|secs?|s\b)"#),
extractSeconds: { groups in Double(groups[1]) ?? 0 }
),
DurationPattern(
pattern: regex(#"in\s+(?:a\s+)?half\s+(?:an?\s+)?hour"#),
extractSeconds: { _ in 30 * 60 }
),
]
}()
// MARK: - Pomodoro Patterns
private struct PomodoroPattern {
let pattern: NSRegularExpression
let extractRounds: ([String]) -> Int
}
private let pomodoroPatterns: [PomodoroPattern] = {
func regex(_ pattern: String) -> NSRegularExpression {
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
return [
PomodoroPattern(
pattern: regex(#"pomodoro\s+(\d+)\s*(?:rounds?|x\b|times?)"#),
extractRounds: { groups in Int(groups[1]) ?? 4 }
),
PomodoroPattern(
pattern: regex(#"(\d+)\s*(?:pomodoros?|poms?)"#),
extractRounds: { groups in Int(groups[1]) ?? 4 }
),
PomodoroPattern(
pattern: regex(#"\bpomodoro\b"#),
extractRounds: { _ in 4 }
),
PomodoroPattern(
pattern: regex(#"\bfocus\s+session\b"#),
extractRounds: { _ in 4 }
),
]
}()
// MARK: - Regex Helpers
private func matchGroups(_ regex: NSRegularExpression, in text: String) -> [String]? {
let range = NSRange(text.startIndex..., in: text)
guard let match = regex.firstMatch(in: text, range: range) else { return nil }
var groups: [String] = []
for i in 0..<match.numberOfRanges {
if let r = Range(match.range(at: i), in: text) {
groups.append(String(text[r]))
} else {
groups.append("")
}
}
return groups
}
// MARK: - Label Extraction
private let labelStripPatterns: [NSRegularExpression] = {
let patterns = [
#"\bin\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b"#,
#"\bin\s+(?:a\s+)?half\s+(?:an?\s+)?hour\b"#,
#"\bat\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?\b"#,
#"\bfor\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b"#,
#"\bpomodoro\s+\d+\s*(?:rounds?|x|times?)\b"#,
#"\b\d+\s*(?:pomodoros?|poms?)\b"#,
#"\bpomodoro\b"#,
#"\bfocus\s+session\b"#,
#"\b(?:timer|alarm|reminder|countdown)\b"#,
#"\b(?:set|create|start|make|add)\s+(?:a\s+)?"#,
#"\b(?:remind\s+me\s+(?:to\s+)?)"#,
#"\b(?:in|at|for)\s*$"#,
#"\bhalf\s+(?:an?\s+)?hour\b"#,
#"\bquarter\s+(?:of\s+)?(?:an?\s+)?hour\b"#,
#"\d{1,2}:\d{2}\s*(?:am|pm)?"#,
#"\d{1,2}\s*(?:am|pm)"#,
]
return patterns.map { try! NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()
private func extractLabel(from input: String) -> String {
var label = input.trimmingCharacters(in: .whitespaces)
for regex in labelStripPatterns {
let range = NSRange(label.startIndex..., in: label)
label = regex.stringByReplacingMatches(in: label, range: range, withTemplate: " ")
}
// Clean up extra whitespace
label = label.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
label = label.trimmingCharacters(in: CharacterSet.whitespaces.union(CharacterSet(charactersIn: ",-—")))
// Capitalize first letter
if let first = label.first {
label = first.uppercased() + label.dropFirst()
}
return label
}
// MARK: - Urgency Detection
private func detectUrgency(from input: String) -> UrgencyLevel {
let lower = input.lowercased()
for (keyword, level) in urgencyKeywords {
if lower.contains(keyword) { return level }
}
return .standard
}
// MARK: - Cascade Selection
private func selectCascade(for urgency: UrgencyLevel) -> CascadePreset {
switch urgency {
case .critical: return .aggressive
case .important: return .standard
case .standard: return .standard
case .gentle: return .minimal
case .passive: return .none
}
}
// MARK: - Main Parser
func parseNaturalLanguage(_ input: String) -> ParseResult {
let trimmed = input.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else {
return ParseResult(success: false, timer: nil, error: "Empty input")
}
let urgency = detectUrgency(from: trimmed)
let cascade = selectCascade(for: urgency)
// 1. Try pomodoro patterns first
for pomo in pomodoroPatterns {
if let groups = matchGroups(pomo.pattern, in: trimmed) {
let rounds = pomo.extractRounds(groups)
let label = extractLabel(from: trimmed).isEmpty ? "Focus Session" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .pomodoro,
label: label,
durationSeconds: nil,
targetTime: nil,
urgency: .standard,
cascade: .minimal,
pomodoroRounds: min(max(rounds, 1), 12),
confidence: 0.9,
raw: trimmed
),
error: nil
)
}
}
// 2. Try "at <time>" patterns (alarm)
let atRegex = try! NSRegularExpression(pattern: #"\bat\s+"#, options: .caseInsensitive)
if matchGroups(atRegex, in: trimmed) != nil {
for tp in timePatterns {
if let groups = matchGroups(tp.pattern, in: trimmed), let time = tp.extractTime(groups) {
let now = Date()
var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
components.hour = time.hours
components.minute = time.minutes
components.second = 0
guard var target = Calendar.current.date(from: components) else { continue }
if target <= now {
target = Calendar.current.date(byAdding: .day, value: 1, to: target) ?? target
}
let label = extractLabel(from: trimmed).isEmpty ? "Alarm" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .alarm,
label: label,
durationSeconds: nil,
targetTime: target,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.95,
raw: trimmed
),
error: nil
)
}
}
}
// 3. Try relative time "in X minutes" (countdown)
for rp in relativePatterns {
if let groups = matchGroups(rp.pattern, in: trimmed) {
let seconds = rp.extractSeconds(groups)
if seconds > 0 {
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .countdown,
label: label,
durationSeconds: seconds,
targetTime: nil,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.9,
raw: trimmed
),
error: nil
)
}
}
}
// 4. Try "for X minutes" duration (countdown)
let forRegex = try! NSRegularExpression(pattern: #"\bfor\s+"#, options: .caseInsensitive)
if matchGroups(forRegex, in: trimmed) != nil {
for dp in durationPatterns {
if let groups = matchGroups(dp.pattern, in: trimmed) {
let seconds = dp.extractSeconds(groups)
if seconds > 0 {
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .countdown,
label: label,
durationSeconds: seconds,
targetTime: nil,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.85,
raw: trimmed
),
error: nil
)
}
}
}
}
// 5. Try bare duration "30 minutes", "1h", etc.
for dp in durationPatterns {
if let groups = matchGroups(dp.pattern, in: trimmed) {
let seconds = dp.extractSeconds(groups)
if seconds > 0 {
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .countdown,
label: label,
durationSeconds: seconds,
targetTime: nil,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.7,
raw: trimmed
),
error: nil
)
}
}
}
// 6. Try bare absolute time without "at" (lower confidence)
for tp in timePatterns {
if let groups = matchGroups(tp.pattern, in: trimmed), let time = tp.extractTime(groups) {
let now = Date()
var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
components.hour = time.hours
components.minute = time.minutes
components.second = 0
guard var target = Calendar.current.date(from: components) else { continue }
if target <= now {
target = Calendar.current.date(byAdding: .day, value: 1, to: target) ?? target
}
let label = extractLabel(from: trimmed).isEmpty ? "Alarm" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .alarm,
label: label,
durationSeconds: nil,
targetTime: target,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.6,
raw: trimmed
),
error: nil
)
}
}
return ParseResult(
success: false,
timer: nil,
error: "Could not parse: \"\(trimmed)\". Try \"meeting in 30 minutes\" or \"alarm at 3pm\"."
)
}

View File

@ -0,0 +1,264 @@
// Recurring Timer Engine
// Recurrence rules, next-occurrence calculation, skip/pause logic
// Ported from web/src/lib/recurrence.ts
import Foundation
// MARK: - Recurrence Frequency
enum RecurrenceFrequency: String, Codable, CaseIterable, Identifiable {
case daily
case weekday
case weekend
case weekly
case biweekly
case monthly
case custom
var id: String { rawValue }
var label: String {
switch self {
case .daily: return "Every day"
case .weekday: return "Weekdays (MonFri)"
case .weekend: return "Weekends (SatSun)"
case .weekly: return "Every week"
case .biweekly: return "Every 2 weeks"
case .monthly: return "Every month"
case .custom: return "Custom days"
}
}
}
// MARK: - Recurrence Rule
struct RecurrenceRule: Codable, Equatable {
let frequency: RecurrenceFrequency
var daysOfWeek: [Int]? // 1=Sun, 2=Mon, ..., 7=Sat (Calendar weekday)
var interval: Int // Every N periods (default 1)
var endDate: Date? // Stop recurring after this
var timeOfDay: Int // Minutes since midnight (e.g., 540 = 9:00 AM)
init(
frequency: RecurrenceFrequency,
daysOfWeek: [Int]? = nil,
interval: Int = 1,
endDate: Date? = nil,
timeOfDay: Int
) {
self.frequency = frequency
self.daysOfWeek = daysOfWeek
self.interval = interval
self.endDate = endDate
self.timeOfDay = timeOfDay
}
}
// MARK: - Recurring Timer
struct RecurringTimer: Codable, Identifiable, Equatable {
let id: String
var recurrence: RecurrenceRule
var paused: Bool
var skipNext: Bool
var lastOccurrence: Date?
}
// MARK: - Next Occurrence Calculation
private let daySeconds: TimeInterval = 24 * 60 * 60
/// Calculate the next occurrence of a recurring timer after `afterDate`.
/// Returns the date of next occurrence, or nil if no more occurrences.
func getNextOccurrence(
rule: RecurrenceRule,
afterDate: Date,
maxLookaheadDays: Int = 366
) -> Date? {
let calendar = Calendar.current
let interval = max(rule.interval, 1)
// Start from the beginning of the afterDate day
let startOfDay = calendar.startOfDay(for: afterDate)
// Helper: set time-of-day on a given date
func setTimeOnDate(_ date: Date) -> Date {
let hours = rule.timeOfDay / 60
let minutes = rule.timeOfDay % 60
return calendar.date(bySettingHour: hours, minute: minutes, second: 0, of: date) ?? date
}
// Check same day first
let sameDayCandidate = setTimeOnDate(startOfDay)
var candidates: [Date] = []
if sameDayCandidate > afterDate {
candidates.append(sameDayCandidate)
}
// Generate candidates going forward
for dayOffset in 1...maxLookaheadDays {
guard let futureDay = calendar.date(byAdding: .day, value: dayOffset, to: startOfDay) else { continue }
candidates.append(setTimeOnDate(futureDay))
}
for candidate in candidates {
// Check end date
if let endDate = rule.endDate, candidate > endDate {
return nil
}
if matchesFrequency(date: candidate, rule: rule, afterDate: afterDate, interval: interval) {
return candidate
}
}
return nil
}
/// Check if a given date matches the recurrence frequency rule.
private func matchesFrequency(
date: Date,
rule: RecurrenceRule,
afterDate: Date,
interval: Int
) -> Bool {
let calendar = Calendar.current
let weekday = calendar.component(.weekday, from: date) // 1=Sun, 2=Mon, ..., 7=Sat
switch rule.frequency {
case .daily:
return true
case .weekday:
return weekday >= 2 && weekday <= 6 // Mon-Fri
case .weekend:
return weekday == 1 || weekday == 7 // Sun or Sat
case .weekly:
let refWeekday = calendar.component(.weekday, from: afterDate)
if weekday != refWeekday { return false }
if interval <= 1 { return true }
let refStart = calendar.startOfDay(for: afterDate)
let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0
let mod = diffDays % (interval * 7)
return mod == 0 || mod == 7
case .biweekly:
let refWeekday = calendar.component(.weekday, from: afterDate)
if weekday != refWeekday { return false }
let refStart = calendar.startOfDay(for: afterDate)
let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0
return diffDays >= 0 && diffDays % 14 < 7
case .monthly:
let refDayOfMonth = calendar.component(.day, from: afterDate)
let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28
let targetDay = min(refDayOfMonth, daysInMonth)
return calendar.component(.day, from: date) == targetDay
case .custom:
guard let days = rule.daysOfWeek else { return false }
return days.contains(weekday)
}
}
// MARK: - Bulk Helpers
/// Get the next N occurrences of a recurring timer.
func getNextNOccurrences(
rule: RecurrenceRule,
afterDate: Date,
count: Int
) -> [Date] {
var occurrences: [Date] = []
var cursor = afterDate
for _ in 0..<count {
guard let next = getNextOccurrence(rule: rule, afterDate: cursor) else { break }
occurrences.append(next)
cursor = next
}
return occurrences
}
/// Apply "skip next" get the occurrence after the next one.
func getOccurrenceAfterSkip(
rule: RecurrenceRule,
afterDate: Date
) -> Date? {
guard let next = getNextOccurrence(rule: rule, afterDate: afterDate) else { return nil }
return getNextOccurrence(rule: rule, afterDate: next)
}
// MARK: - Rule Builders
func createDailyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .daily, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createWeekdayRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .weekday, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createWeekendRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .weekend, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createWeeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .weekly, interval: 1, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createBiweeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .biweekly, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createMonthlyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .monthly, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createCustomRule(daysOfWeek: [Int], timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .custom, daysOfWeek: daysOfWeek, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
// MARK: - Display Helpers
/// Format time-of-day minutes as "HH:MM AM/PM"
func formatTimeOfDay(minutes: Int) -> String {
let h = minutes / 60
let m = minutes % 60
let period = h >= 12 ? "PM" : "AM"
let h12 = h == 0 ? 12 : (h > 12 ? h - 12 : h)
return "\(h12):\(String(format: "%02d", m)) \(period)"
}
/// Get a human-readable description of a recurrence rule.
func describeRecurrence(rule: RecurrenceRule) -> String {
let time = formatTimeOfDay(minutes: rule.timeOfDay)
let dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
switch rule.frequency {
case .daily:
return "Every day at \(time)"
case .weekday:
return "Weekdays at \(time)"
case .weekend:
return "Weekends at \(time)"
case .weekly:
return "Every week at \(time)"
case .biweekly:
return "Every 2 weeks at \(time)"
case .monthly:
return "Monthly at \(time)"
case .custom:
guard let dows = rule.daysOfWeek, !dows.isEmpty else { return "Custom at \(time)" }
// Convert Calendar weekday (1=Sun) to dayNames index (0=Sun)
let days = dows.sorted().compactMap { d -> String? in
let idx = d - 1
guard idx >= 0 && idx < dayNames.count else { return nil }
return dayNames[idx]
}.joined(separator: ", ")
return "\(days) at \(time)"
}
}

View File

@ -0,0 +1,345 @@
// Routine Engine
// Ordered sequences of timed steps with transitions, state machine, and templates
// Ported from web/src/lib/routines.ts
import Foundation
// MARK: - Types
enum TransitionType: String, Codable, CaseIterable, Identifiable {
case immediate
case oneMinBreak = "1m_break"
case fiveMinBreak = "5m_break"
case custom
var id: String { rawValue }
var label: String {
switch self {
case .immediate: return "Immediate"
case .oneMinBreak: return "1 min break"
case .fiveMinBreak: return "5 min break"
case .custom: return "Custom break"
}
}
var minutes: Int {
switch self {
case .immediate: return 0
case .oneMinBreak: return 1
case .fiveMinBreak: return 5
case .custom: return 0
}
}
}
enum RoutineStatus: String, Codable {
case template
case ready
case active
case paused
case completed
case cancelled
}
enum StepStatus: String, Codable {
case pending
case active
case skipped
case completed
}
// MARK: - Routine Step
struct CMRoutineStep: Codable, Identifiable, Equatable {
let id: String
var label: String
var durationMinutes: Int
var transition: TransitionType
var customTransitionMinutes: Int?
var notes: String?
var status: StepStatus
var startedAt: Date?
var completedAt: Date?
init(
id: String = UUID().uuidString,
label: String,
durationMinutes: Int,
transition: TransitionType = .immediate,
customTransitionMinutes: Int? = nil,
notes: String? = nil,
status: StepStatus = .pending,
startedAt: Date? = nil,
completedAt: Date? = nil
) {
self.id = id
self.label = label
self.durationMinutes = durationMinutes
self.transition = transition
self.customTransitionMinutes = customTransitionMinutes
self.notes = notes
self.status = status
self.startedAt = startedAt
self.completedAt = completedAt
}
var transitionMinutes: Int {
switch transition {
case .custom: return customTransitionMinutes ?? 0
default: return transition.minutes
}
}
}
// MARK: - Routine
struct CMRoutine: Codable, Identifiable, Equatable {
let id: String
var name: String
var routineDescription: String?
var steps: [CMRoutineStep]
var totalDurationMinutes: Int
var status: RoutineStatus
var currentStepIndex: Int
let createdAt: Date
var startedAt: Date?
var pausedAt: Date?
var completedAt: Date?
var elapsedBeforePause: TimeInterval
var isTemplate: Bool
init(
id: String = UUID().uuidString,
name: String,
routineDescription: String? = nil,
steps: [CMRoutineStep],
isTemplate: Bool = false
) {
self.id = id
self.name = name
self.routineDescription = routineDescription
self.steps = steps
self.totalDurationMinutes = CMRoutine.calculateTotalDuration(steps: steps)
self.status = isTemplate ? .template : .ready
self.currentStepIndex = 0
self.createdAt = Date()
self.startedAt = nil
self.pausedAt = nil
self.completedAt = nil
self.elapsedBeforePause = 0
self.isTemplate = isTemplate
}
static func calculateTotalDuration(steps: [CMRoutineStep]) -> Int {
steps.enumerated().reduce(0) { total, pair in
let (idx, step) = pair
let transition = idx < steps.count - 1 ? step.transitionMinutes : 0
return total + step.durationMinutes + transition
}
}
}
// MARK: - State Machine
extension CMRoutine {
mutating func start() {
guard status == .ready || status == .template else { return }
guard !steps.isEmpty else { return }
let now = Date()
for i in steps.indices {
steps[i].status = i == 0 ? .active : .pending
steps[i].startedAt = i == 0 ? now : nil
steps[i].completedAt = nil
}
status = .active
currentStepIndex = 0
startedAt = now
pausedAt = nil
completedAt = nil
elapsedBeforePause = 0
}
mutating func pause() {
guard status == .active else { return }
let now = Date()
if let currentStep = currentStep, let stepStart = currentStep.startedAt {
elapsedBeforePause += now.timeIntervalSince(stepStart)
}
status = .paused
pausedAt = now
}
mutating func resume() {
guard status == .paused else { return }
let now = Date()
steps[currentStepIndex].startedAt = now
status = .active
pausedAt = nil
}
mutating func completeCurrentStep() {
guard status == .active else { return }
guard currentStepIndex < steps.count else { return }
let now = Date()
steps[currentStepIndex].status = .completed
steps[currentStepIndex].completedAt = now
let nextIndex = currentStepIndex + 1
if nextIndex >= steps.count {
status = .completed
completedAt = now
currentStepIndex = nextIndex
elapsedBeforePause = 0
} else {
steps[nextIndex].status = .active
steps[nextIndex].startedAt = now
currentStepIndex = nextIndex
elapsedBeforePause = 0
}
}
mutating func skipCurrentStep() {
guard status == .active else { return }
guard currentStepIndex < steps.count else { return }
let now = Date()
steps[currentStepIndex].status = .skipped
steps[currentStepIndex].completedAt = now
let nextIndex = currentStepIndex + 1
if nextIndex >= steps.count {
status = .completed
completedAt = now
currentStepIndex = nextIndex
elapsedBeforePause = 0
} else {
steps[nextIndex].status = .active
steps[nextIndex].startedAt = now
currentStepIndex = nextIndex
elapsedBeforePause = 0
}
}
mutating func cancel() {
guard status != .completed && status != .cancelled else { return }
status = .cancelled
completedAt = Date()
}
}
// MARK: - Utility
extension CMRoutine {
var currentStep: CMRoutineStep? {
guard currentStepIndex < steps.count else { return nil }
return steps[currentStepIndex]
}
var nextStep: CMRoutineStep? {
let nextIdx = currentStepIndex + 1
guard nextIdx < steps.count else { return nil }
return steps[nextIdx]
}
var completedStepCount: Int {
steps.filter { $0.status == .completed }.count
}
var skippedStepCount: Int {
steps.filter { $0.status == .skipped }.count
}
var progress: Double {
guard !steps.isEmpty else { return 0 }
let done = steps.filter { $0.status == .completed || $0.status == .skipped }.count
return Double(done) / Double(steps.count)
}
func remainingStepSeconds(now: Date = Date()) -> TimeInterval {
if status == .paused {
guard let currentStep = currentStep else { return 0 }
return TimeInterval(currentStep.durationMinutes * 60) - elapsedBeforePause
}
guard status == .active else { return 0 }
guard let currentStep = currentStep, let stepStart = currentStep.startedAt else { return 0 }
let elapsed = elapsedBeforePause + now.timeIntervalSince(stepStart)
return max(0, TimeInterval(currentStep.durationMinutes * 60) - elapsed)
}
func shouldStepComplete(now: Date = Date()) -> Bool {
status == .active && remainingStepSeconds(now: now) <= 0
}
func instantiate() -> CMRoutine {
guard isTemplate || status == .template else { return self }
let newSteps = steps.map { step in
CMRoutineStep(
label: step.label,
durationMinutes: step.durationMinutes,
transition: step.transition,
customTransitionMinutes: step.customTransitionMinutes,
notes: step.notes
)
}
return CMRoutine(name: name, routineDescription: routineDescription, steps: newSteps)
}
}
// MARK: - Built-in Templates
struct RoutineTemplates {
static let morning = CMRoutine(
name: "Morning Routine",
routineDescription: "Start your day with intention",
steps: [
CMRoutineStep(label: "Wake Up + Hydrate", durationMinutes: 5, notes: "Drink a glass of water"),
CMRoutineStep(label: "Meditation", durationMinutes: 15, transition: .oneMinBreak, notes: "Mindfulness or breathing exercise"),
CMRoutineStep(label: "Exercise", durationMinutes: 30, transition: .oneMinBreak, notes: "Workout, yoga, or a walk"),
CMRoutineStep(label: "Shower + Get Ready", durationMinutes: 20),
CMRoutineStep(label: "Breakfast", durationMinutes: 15),
],
isTemplate: true
)
static let workout = CMRoutine(
name: "Workout",
routineDescription: "Structured workout with warm-up and cool-down",
steps: [
CMRoutineStep(label: "Warm Up", durationMinutes: 5, notes: "Light stretching and mobility"),
CMRoutineStep(label: "Main Workout", durationMinutes: 30, transition: .oneMinBreak, notes: "Strength or cardio"),
CMRoutineStep(label: "Cool Down", durationMinutes: 5, notes: "Stretching and foam rolling"),
CMRoutineStep(label: "Hydrate + Recovery", durationMinutes: 5),
],
isTemplate: true
)
static let cookingPrep = CMRoutine(
name: "Cooking Prep",
routineDescription: "Organized meal preparation",
steps: [
CMRoutineStep(label: "Gather Ingredients", durationMinutes: 5, notes: "Get everything out"),
CMRoutineStep(label: "Prep & Chop", durationMinutes: 15, notes: "Wash, peel, and chop"),
CMRoutineStep(label: "Cook", durationMinutes: 25, transition: .fiveMinBreak, notes: "Follow the recipe"),
CMRoutineStep(label: "Plate & Serve", durationMinutes: 5),
],
isTemplate: true
)
static let eveningWindDown = CMRoutine(
name: "Evening Wind-Down",
routineDescription: "Prepare your mind and body for rest",
steps: [
CMRoutineStep(label: "Screen-Free Time", durationMinutes: 15, notes: "Put devices away"),
CMRoutineStep(label: "Light Stretching", durationMinutes: 10, transition: .oneMinBreak, notes: "Gentle yoga or stretching"),
CMRoutineStep(label: "Journal / Reflect", durationMinutes: 10, notes: "Write about your day"),
CMRoutineStep(label: "Read", durationMinutes: 20, notes: "Read a book or magazine"),
],
isTemplate: true
)
static let all: [CMRoutine] = [morning, workout, cookingPrep, eveningWindDown]
}

View File

@ -0,0 +1,243 @@
// Routine Editor View
// Create or edit a routine with steps, durations, and transitions
import SwiftUI
struct RoutineEditorView: View {
let routine: CMRoutine?
let onSave: (CMRoutine) -> Void
let onCancel: () -> Void
@State private var name: String = ""
@State private var description: String = ""
@State private var steps: [CMRoutineStep] = []
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Name & description
VStack(alignment: .leading, spacing: 8) {
Text("Name")
.font(.subheadline.bold())
.foregroundColor(CMColors.textSecondary)
TextField("Morning Routine", text: $name)
.textFieldStyle(.plain)
.padding(10)
.background(CMColors.surfaceHover)
.cornerRadius(8)
.foregroundColor(CMColors.text)
Text("Description")
.font(.subheadline.bold())
.foregroundColor(CMColors.textSecondary)
TextField("Start your day with intention", text: $description)
.textFieldStyle(.plain)
.padding(10)
.background(CMColors.surfaceHover)
.cornerRadius(8)
.foregroundColor(CMColors.text)
}
// Steps
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Steps")
.font(.subheadline.bold())
.foregroundColor(CMColors.textSecondary)
Spacer()
Text("\(totalDuration) min total")
.font(.caption)
.foregroundColor(CMColors.accent)
}
ForEach(Array(steps.enumerated()), id: \.element.id) { idx, step in
stepRow(step, index: idx)
}
Button {
steps.append(CMRoutineStep(
label: "",
durationMinutes: 10
))
} label: {
Label("Add Step", systemImage: "plus.circle.fill")
.font(.subheadline)
.foregroundColor(CMColors.accent)
}
.padding(.top, 4)
}
}
.padding()
}
.background(CMColors.bg.ignoresSafeArea())
.navigationTitle(routine == nil ? "New Routine" : "Edit Routine")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onCancel() }
.foregroundColor(CMColors.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.foregroundColor(CMColors.accent)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || steps.isEmpty)
}
}
.onAppear {
if let r = routine {
name = r.name
description = r.routineDescription ?? ""
steps = r.steps
}
}
}
}
// MARK: - Step Row
@ViewBuilder
private func stepRow(_ step: CMRoutineStep, index: Int) -> some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
// Drag handle / index
Text("\(index + 1)")
.font(.caption.bold())
.foregroundColor(CMColors.textMuted)
.frame(width: 20)
// Label
TextField("Step name", text: Binding(
get: { steps[safe: index]?.label ?? "" },
set: { if steps.indices.contains(index) { steps[index].label = $0 } }
))
.textFieldStyle(.plain)
.padding(8)
.background(CMColors.surfaceHover)
.cornerRadius(6)
.foregroundColor(CMColors.text)
.font(.subheadline)
// Duration stepper
HStack(spacing: 4) {
Button {
if steps.indices.contains(index) && steps[index].durationMinutes > 1 {
steps[index].durationMinutes -= 1
}
} label: {
Image(systemName: "minus")
.font(.caption2)
.foregroundColor(CMColors.textSecondary)
}
Text("\(steps[safe: index]?.durationMinutes ?? 0)m")
.font(.caption.monospacedDigit())
.foregroundColor(CMColors.accent)
.frame(width: 30)
Button {
if steps.indices.contains(index) {
steps[index].durationMinutes += 1
}
} label: {
Image(systemName: "plus")
.font(.caption2)
.foregroundColor(CMColors.textSecondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(CMColors.surface)
.cornerRadius(6)
// Delete
Button {
steps.remove(at: index)
} label: {
Image(systemName: "trash")
.font(.caption)
.foregroundColor(CMColors.critical.opacity(0.7))
}
}
// Transition picker (not on last step)
if index < steps.count - 1 {
HStack(spacing: 8) {
Spacer().frame(width: 20)
Image(systemName: "arrow.down")
.font(.caption2)
.foregroundColor(CMColors.textMuted)
Picker("Transition", selection: Binding(
get: { steps[safe: index]?.transition ?? .immediate },
set: { if steps.indices.contains(index) { steps[index].transition = $0 } }
)) {
ForEach(TransitionType.allCases) { t in
Text(t.label).tag(t)
}
}
.pickerStyle(.menu)
.tint(CMColors.textSecondary)
.font(.caption)
Spacer()
}
}
// Notes
TextField("Notes (optional)", text: Binding(
get: { steps[safe: index]?.notes ?? "" },
set: { if steps.indices.contains(index) { steps[index].notes = $0.isEmpty ? nil : $0 } }
))
.textFieldStyle(.plain)
.padding(6)
.background(CMColors.surfaceHover.opacity(0.5))
.cornerRadius(4)
.foregroundColor(CMColors.textMuted)
.font(.caption)
.padding(.leading, 28)
}
.padding(10)
.background(CMColors.surface)
.cornerRadius(8)
}
// MARK: - Computed
private var totalDuration: Int {
CMRoutine.calculateTotalDuration(steps: steps)
}
// MARK: - Actions
private func save() {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty, !steps.isEmpty else { return }
let validSteps = steps.filter { !$0.label.trimmingCharacters(in: .whitespaces).isEmpty }
guard !validSteps.isEmpty else { return }
if var existing = routine {
existing.name = trimmedName
existing.routineDescription = description.isEmpty ? nil : description
existing.steps = validSteps
existing.totalDurationMinutes = CMRoutine.calculateTotalDuration(steps: validSteps)
onSave(existing)
} else {
let newRoutine = CMRoutine(
name: trimmedName,
routineDescription: description.isEmpty ? nil : description,
steps: validSteps
)
onSave(newRoutine)
}
}
}
// MARK: - Safe Array Subscript
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@ -0,0 +1,210 @@
// Routine List View
// Displays built-in templates and user routines with start/edit actions
import SwiftUI
struct RoutineListView: View {
@State private var routines: [CMRoutine] = RoutineTemplates.all
@State private var activeRoutine: CMRoutine?
@State private var showEditor = false
@State private var editingRoutine: CMRoutine?
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Active routine banner
if let active = activeRoutine {
activeRoutineBanner(active)
}
// Templates section
sectionHeader("Templates")
ForEach(routines.filter { $0.isTemplate }) { routine in
routineCard(routine)
}
// User routines section
let userRoutines = routines.filter { !$0.isTemplate }
if !userRoutines.isEmpty {
sectionHeader("My Routines")
ForEach(userRoutines) { routine in
routineCard(routine)
}
}
}
.padding()
}
.background(CMColors.bg.ignoresSafeArea())
.navigationTitle("Routines")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
editingRoutine = nil
showEditor = true
} label: {
Image(systemName: "plus")
.foregroundColor(CMColors.accent)
}
}
}
.sheet(isPresented: $showEditor) {
RoutineEditorView(
routine: editingRoutine,
onSave: { routine in
if let idx = routines.firstIndex(where: { $0.id == routine.id }) {
routines[idx] = routine
} else {
routines.append(routine)
}
showEditor = false
},
onCancel: { showEditor = false }
)
}
.fullScreenCover(item: $activeRoutine) { routine in
RoutineRunnerView(
routine: routine,
onComplete: { completed in
activeRoutine = nil
if let idx = routines.firstIndex(where: { $0.id == completed.id }) {
routines[idx] = completed
}
},
onCancel: {
activeRoutine = nil
}
)
}
}
// MARK: - Subviews
@ViewBuilder
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.headline)
.foregroundColor(CMColors.textSecondary)
Spacer()
}
}
@ViewBuilder
private func routineCard(_ routine: CMRoutine) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(routine.name)
.font(.title3.bold())
.foregroundColor(CMColors.text)
if let desc = routine.routineDescription {
Text(desc)
.font(.subheadline)
.foregroundColor(CMColors.textSecondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(routine.totalDurationMinutes) min")
.font(.caption.bold())
.foregroundColor(CMColors.accent)
Text("\(routine.steps.count) steps")
.font(.caption2)
.foregroundColor(CMColors.textMuted)
}
}
// Step preview
HStack(spacing: 4) {
ForEach(routine.steps.prefix(5)) { step in
Text(step.label)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(CMColors.surfaceHover)
.cornerRadius(4)
.foregroundColor(CMColors.textSecondary)
}
if routine.steps.count > 5 {
Text("+\(routine.steps.count - 5)")
.font(.caption2)
.foregroundColor(CMColors.textMuted)
}
}
// Actions
HStack(spacing: 12) {
Button {
var instance = routine.isTemplate ? routine.instantiate() : routine
instance.start()
activeRoutine = instance
} label: {
Label("Start", systemImage: "play.fill")
.font(.subheadline.bold())
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(CMColors.accent)
.cornerRadius(8)
}
if !routine.isTemplate {
Button {
editingRoutine = routine
showEditor = true
} label: {
Label("Edit", systemImage: "pencil")
.font(.subheadline)
.foregroundColor(CMColors.textSecondary)
}
}
}
.padding(.top, 4)
}
.padding()
.background(CMColors.surface)
.cornerRadius(12)
}
@ViewBuilder
private func activeRoutineBanner(_ routine: CMRoutine) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Active Routine")
.font(.caption.bold())
.foregroundColor(CMColors.accent)
Text(routine.name)
.font(.headline)
.foregroundColor(CMColors.text)
if let step = routine.currentStep {
Text("Step: \(step.label)")
.font(.subheadline)
.foregroundColor(CMColors.textSecondary)
}
}
Spacer()
Button("Resume") {
activeRoutine = routine
}
.font(.subheadline.bold())
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(CMColors.accent)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(CMColors.surface)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
)
)
}
}

View File

@ -0,0 +1,348 @@
// Routine Runner View
// Full-screen view for executing a routine step-by-step
import SwiftUI
struct RoutineRunnerView: View {
@State var routine: CMRoutine
let onComplete: (CMRoutine) -> Void
let onCancel: () -> Void
@State private var now = Date()
@State private var timer: Timer?
var body: some View {
ZStack {
CMColors.bg.ignoresSafeArea()
VStack(spacing: 0) {
// Header
header
// Progress bar
progressBar
// Current step
if let step = routine.currentStep {
currentStepCard(step)
} else if routine.status == .completed {
completedView
}
Spacer()
// Step list
stepList
// Controls
controls
}
}
.onAppear { startTicking() }
.onDisappear { stopTicking() }
}
// MARK: - Header
private var header: some View {
HStack {
Button {
routine.cancel()
onCancel()
} label: {
Image(systemName: "xmark")
.font(.title3)
.foregroundColor(CMColors.textSecondary)
}
Spacer()
VStack(spacing: 2) {
Text(routine.name)
.font(.headline)
.foregroundColor(CMColors.text)
Text("Step \(min(routine.currentStepIndex + 1, routine.steps.count)) of \(routine.steps.count)")
.font(.caption)
.foregroundColor(CMColors.textSecondary)
}
Spacer()
// Spacer for symmetry
Color.clear.frame(width: 28, height: 28)
}
.padding()
}
// MARK: - Progress Bar
private var progressBar: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(CMColors.surfaceHover)
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(CMColors.accent)
.frame(width: geo.size.width * routine.progress, height: 4)
.animation(.easeInOut(duration: 0.3), value: routine.progress)
}
}
.frame(height: 4)
.padding(.horizontal)
}
// MARK: - Current Step Card
@ViewBuilder
private func currentStepCard(_ step: CMRoutineStep) -> some View {
VStack(spacing: 16) {
Spacer().frame(height: 20)
// Step label
Text(step.label)
.font(.largeTitle.bold())
.foregroundColor(CMColors.text)
.multilineTextAlignment(.center)
// Remaining time
let remaining = routine.remainingStepSeconds(now: now)
Text(formatRemaining(remaining))
.font(.system(size: 60, weight: .thin, design: .monospaced))
.foregroundColor(remaining <= 30 ? CMColors.critical : CMColors.accent)
// Notes
if let notes = step.notes, !notes.isEmpty {
Text(notes)
.font(.subheadline)
.foregroundColor(CMColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// Next step preview
if let next = routine.nextStep {
HStack(spacing: 4) {
Text("Next:")
.font(.caption)
.foregroundColor(CMColors.textMuted)
Text(next.label)
.font(.caption.bold())
.foregroundColor(CMColors.textSecondary)
if step.transition != .immediate {
Text("(\(step.transition.label))")
.font(.caption2)
.foregroundColor(CMColors.textMuted)
}
}
.padding(.top, 8)
}
Spacer()
}
}
// MARK: - Completed View
private var completedView: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundColor(CMColors.gentle)
Text("Routine Complete!")
.font(.largeTitle.bold())
.foregroundColor(CMColors.text)
HStack(spacing: 24) {
VStack {
Text("\(routine.completedStepCount)")
.font(.title.bold())
.foregroundColor(CMColors.accent)
Text("Completed")
.font(.caption)
.foregroundColor(CMColors.textSecondary)
}
if routine.skippedStepCount > 0 {
VStack {
Text("\(routine.skippedStepCount)")
.font(.title.bold())
.foregroundColor(CMColors.important)
Text("Skipped")
.font(.caption)
.foregroundColor(CMColors.textSecondary)
}
}
}
Spacer()
Button {
onComplete(routine)
} label: {
Text("Done")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(CMColors.accent)
.cornerRadius(12)
}
.padding(.horizontal, 32)
.padding(.bottom, 32)
}
}
// MARK: - Step List
private var stepList: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(routine.steps.enumerated()), id: \.element.id) { idx, step in
stepPill(step, index: idx)
}
}
.padding(.horizontal)
}
.padding(.vertical, 8)
}
@ViewBuilder
private func stepPill(_ step: CMRoutineStep, index: Int) -> some View {
let isCurrent = index == routine.currentStepIndex && routine.status == .active
HStack(spacing: 4) {
statusIcon(step.status)
.font(.caption2)
Text(step.label)
.font(.caption2)
.lineLimit(1)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isCurrent ? CMColors.accent.opacity(0.2) : CMColors.surfaceHover)
.cornerRadius(16)
.foregroundColor(isCurrent ? CMColors.accent : statusColor(step.status))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isCurrent ? CMColors.accent : Color.clear, lineWidth: 1)
)
}
@ViewBuilder
private func statusIcon(_ status: StepStatus) -> some View {
switch status {
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(CMColors.gentle)
case .skipped:
Image(systemName: "forward.fill")
.foregroundColor(CMColors.important)
case .active:
Image(systemName: "play.circle.fill")
.foregroundColor(CMColors.accent)
case .pending:
Image(systemName: "circle")
.foregroundColor(CMColors.textMuted)
}
}
private func statusColor(_ status: StepStatus) -> Color {
switch status {
case .completed: return CMColors.gentle
case .skipped: return CMColors.important
case .active: return CMColors.accent
case .pending: return CMColors.textMuted
}
}
// MARK: - Controls
private var controls: some View {
HStack(spacing: 16) {
if routine.status == .active {
// Skip button
Button {
routine.skipCurrentStep()
checkCompletion()
} label: {
Image(systemName: "forward.fill")
.font(.title2)
.foregroundColor(CMColors.textSecondary)
.frame(width: 56, height: 56)
.background(CMColors.surfaceHover)
.clipShape(Circle())
}
// Complete step button
Button {
routine.completeCurrentStep()
checkCompletion()
} label: {
Image(systemName: "checkmark")
.font(.title.bold())
.foregroundColor(.white)
.frame(width: 72, height: 72)
.background(CMColors.accent)
.clipShape(Circle())
}
// Pause button
Button {
routine.pause()
} label: {
Image(systemName: "pause.fill")
.font(.title2)
.foregroundColor(CMColors.textSecondary)
.frame(width: 56, height: 56)
.background(CMColors.surfaceHover)
.clipShape(Circle())
}
} else if routine.status == .paused {
Button {
routine.resume()
} label: {
Image(systemName: "play.fill")
.font(.title.bold())
.foregroundColor(.white)
.frame(width: 72, height: 72)
.background(CMColors.accent)
.clipShape(Circle())
}
}
}
.padding(.bottom, 32)
}
// MARK: - Helpers
private func formatRemaining(_ seconds: TimeInterval) -> String {
let total = Int(max(0, seconds))
let m = total / 60
let s = total % 60
return String(format: "%d:%02d", m, s)
}
private func startTicking() {
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
now = Date()
if routine.shouldStepComplete(now: now) {
routine.completeCurrentStep()
checkCompletion()
}
}
}
private func stopTicking() {
timer?.invalidate()
timer = nil
}
private func checkCompletion() {
if routine.status == .completed {
stopTicking()
}
}
}

8
web/env.example Normal file
View File

@ -0,0 +1,8 @@
# ── ChronoMind Web — Environment Variables ────────────────────
# Copy this file to .env.local and fill in values.
# Platform-service URL (for cloud sync — optional for local-only use)
NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003
# Analytics (optional — stub logs to console in dev)
# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=chronomind.app

View File

@ -63,8 +63,8 @@ const STORAGE_KEYS = {
} as const; } as const;
function getBaseUrl(): string { function getBaseUrl(): string {
if (typeof window !== 'undefined' && (window as Record<string, unknown>).__PLATFORM_URL__) { if (typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__PLATFORM_URL__) {
return (window as Record<string, unknown>).__PLATFORM_URL__ as string; return (window as unknown as Record<string, unknown>).__PLATFORM_URL__ as string;
} }
return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app';
} }