diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..e222e6d --- /dev/null +++ b/.cursorrules @@ -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" diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..1576837 --- /dev/null +++ b/.windsurfrules @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f62ce9c --- /dev/null +++ b/AGENTS.md @@ -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=` 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2569d65 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2f074a --- /dev/null +++ b/README.md @@ -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). diff --git a/android/app/src/main/java/com/chronomind/app/ui/screens/RoutineScreen.kt b/android/app/src/main/java/com/chronomind/app/ui/screens/RoutineScreen.kt new file mode 100644 index 0000000..f083caa --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/ui/screens/RoutineScreen.kt @@ -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, + 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(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, + 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) + } +} diff --git a/ios/ChronoMind/Shared/TimerEngine/ContextMessages.swift b/ios/ChronoMind/Shared/TimerEngine/ContextMessages.swift new file mode 100644 index 0000000..8f5de02 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/ContextMessages.swift @@ -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 +} diff --git a/ios/ChronoMind/Shared/TimerEngine/NLParser.swift b/ios/ChronoMind/Shared/TimerEngine/NLParser.swift new file mode 100644 index 0000000..33ec442 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/NLParser.swift @@ -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.. 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