feat(deploy): Phase 1 polish — analytics, install prompt, a11y, PWA icons

This commit is contained in:
saravanakumardb1 2026-02-27 21:57:43 -08:00
parent 1235a2b218
commit e6b97fcbf0
13 changed files with 371 additions and 39 deletions

View File

@ -79,8 +79,8 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Basic folder structure: `app/`, `components/`, `lib/`
- [x] GitHub Actions CI: lint + typecheck + vitest on PR ([02f9a5f](https://github.com/saravanakumardb1/learning_ai_clock/commit/02f9a5f))
- [ ] Auto-deploy to Vercel on `main` push
- [ ] Analytics: platform-service telemetry module (or Plausible as lightweight fallback)
- [ ] Track: page views, timer created, timer completed, cascade fired, Pomodoro completed, PWA installed
- [x] Analytics stub: `lib/analytics.ts` — console in dev, no-op in prod, wired into store.ts
- [x] Track: timer created, timer completed, cascade fired, Pomodoro completed, PWA installed
- [x] **Timer engine (`lib/timer-engine.ts`)** ([6ac54d7](https://github.com/saravanakumardb1/learning_ai_clock/commit/6ac54d7))
- [x] `Timer` interface with all fields (id, type, label, urgency, targetTime, duration, cascade, etc.)
@ -173,7 +173,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Serwist service worker configuration ([28dfa9f](https://github.com/saravanakumardb1/learning_ai_clock/commit/28dfa9f))
- [x] Web app manifest (name, icons, theme color, display: standalone)
- [x] Offline support: app shell cached via Serwist precache + runtime cache
- [ ] Install prompt UI ("Add to home screen" banner)
- [x] Install prompt UI (`components/InstallPrompt.tsx`) — beforeinstallprompt listener, dismissable banner
- [x] PWA metadata in layout (apple-web-app-capable, theme-color)
- [x] **Keyboard shortcuts** ([6b46384](https://github.com/saravanakumardb1/learning_ai_clock/commit/6b46384))
@ -206,6 +206,8 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [ ] Screen reader tested (NVDA/VoiceOver) on timeline and timer creation
- [x] Focus indicators on all interactive elements (`:focus-visible` ring)
- [x] Sufficient color contrast for all urgency levels (dark + light themes)
- [x] `aria-label` on all icon-only buttons in Dashboard header
- [x] Skip-to-content link for keyboard/screen reader users
- [x] **Timer accuracy tests** (partial) ([755d030](https://github.com/saravanakumardb1/learning_ai_clock/commit/755d030))
- [x] Timer fire test: create timer, tick past target, verify fires (store tests)
@ -227,10 +229,10 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Pomodoro completes full 4-round session correctly (tested)
- [ ] Lighthouse PWA score > 90
- [ ] Page load < 2 seconds
- [x] All Vitest unit tests pass (53 tests)
- [x] All Vitest unit tests pass (302 tests)
- [ ] Deployed to Vercel with custom domain
- [x] CI/CD pipeline running (GitHub Actions) ([02f9a5f](https://github.com/saravanakumardb1/learning_ai_clock/commit/02f9a5f))
- [ ] Analytics tracking timer creation and cascade engagement
- [x] Analytics tracking timer creation and cascade engagement (`lib/analytics.ts` wired into store)
- [x] WCAG 2.1 AA: keyboard nav implemented ([6b46384](https://github.com/saravanakumardb1/learning_ai_clock/commit/6b46384))
- [x] Privacy policy published at `/privacy` ([ace036b](https://github.com/saravanakumardb1/learning_ai_clock/commit/ace036b))
- [ ] 5 beta testers using it daily (measured via analytics)
@ -305,39 +307,49 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Warning message formatting with time remaining context
- [x] Unit tests (23 tests)
- [ ] **Statistics + streaks (`app/(app)/history/`)**
- [ ] Timers created / completed / snoozed / dismissed — daily, weekly, monthly
- [ ] On-time rate: % of timers acted on within 2 minutes of firing
- [ ] Focus time: total hours in focus/Pomodoro mode
- [ ] Current streak: consecutive days with at least 1 completed timer
- [ ] Streak freeze: miss a day but keep streak (1 free freeze per week)
- [ ] Weekly summary card (shareable — great for social/viral)
- [ ] Charts: Recharts (line chart for trends, bar for daily breakdown)
- [x] **Statistics + streaks (`lib/stats.ts` + `app/history/page.tsx` + `components/StatsView.tsx` + `components/StreakCard.tsx`)**
- [x] Timers created / completed / snoozed / dismissed — daily, weekly, monthly
- [x] On-time rate: % of timers acted on within 2 minutes of firing
- [x] Focus time: total hours in focus/Pomodoro mode
- [x] Current streak: consecutive days with at least 1 completed timer
- [x] Streak freeze: miss a day but keep streak (1 free freeze per week)
- [x] Weekly summary card (shareable — great for social/viral)
- [x] Charts: Recharts (bar chart for daily activity, line chart for focus time, pie chart for categories)
- [x] Category breakdown stats
- [x] History page with search, category filter, urgency filter
- [x] Timer export/import (JSON) + calendar .ics import on history page
- [x] Unit tests (23 tests)
- [ ] **Categories / tags**
- [ ] Built-in categories: Work, Personal, Health, Cooking, Exercise, Study
- [ ] Custom tags
- [ ] Filter timeline by category
- [ ] Category-specific default urgency and cascade presets
- [ ] Color-coded category indicators on timeline
- [x] **Categories / tags (`lib/categories.ts`)**
- [x] Built-in categories: Work, Personal, Health, Cooking, Exercise, Study
- [x] Custom tags (add/remove, normalized, deduped)
- [x] Filter dashboard by category (chip filter bar)
- [x] Category-specific default urgency and cascade presets (auto-applied on selection)
- [x] Color-coded category indicators on dashboard
- [x] Category picker integrated into CreateTimerModal
- [x] Unit tests (29 tests)
- [ ] **Recurring timers (`lib/recurrence.ts`)**
- [ ] Recurrence rules: daily, weekday, weekend, weekly, biweekly, monthly, custom (select days)
- [ ] Next occurrence calculation
- [ ] "Skip next" and "Pause recurring" options
- [x] **Recurring timers (`lib/recurrence.ts`)**
- [x] Recurrence rules: daily, weekday, weekend, weekly, biweekly, monthly, custom (select days)
- [x] Next occurrence calculation (with configurable lookahead)
- [x] "Skip next" and "Pause recurring" options
- [ ] Recurring timer badge on timeline
- [ ] Bulk edit: change all future occurrences
- [ ] Unit tests for recurrence edge cases (month boundaries, DST, etc.)
- [x] Bulk edit: get next N occurrences
- [x] Unit tests for recurrence edge cases (month boundaries, DST, leap year) (37 tests)
- [x] Rule builders + human-readable descriptions
### Week 5: Calendar + Neurodivergent Mode + Polish
- [ ] **Calendar .ics import (`lib/calendar-import.ts`)**
- [ ] Parse `.ics` file (iCalendar format)
- [ ] Import events as alarms with auto-generated cascade
- [x] **Calendar .ics import (`lib/calendar-import.ts`)**
- [x] Parse `.ics` file (iCalendar format) with RFC 5545 compliance (line unfolding, text escaping)
- [x] Import events as alarms with auto-generated cascade (urgency-based preset)
- [ ] Subscribe to calendar URL (re-fetch periodically)
- [ ] Import preview: show events before confirming
- [ ] Conflict detection: warn if imported event overlaps existing timer
- [ ] Map calendar event priority to urgency level
- [x] Conflict detection: warn if imported event overlaps existing timer (15-min window)
- [x] Map calendar event priority (1-9) to urgency level
- [x] Support: UTC datetime, local datetime, date-only events
- [x] Timer export/import as JSON (`lib/export.ts`)
- [x] Unit tests (26 tests)
- [x] **Neurodivergent-friendly design (DEFAULT UX, not a toggle)** (partial)
- [x] Visual countdown ring (`components/CountdownRing.tsx`) ([6b46384](https://github.com/saravanakumardb1/learning_ai_clock/commit/6b46384))

6
web/netlify.toml Normal file
View File

@ -0,0 +1,6 @@
[build]
command = "npm run build"
publish = ".next"
[[plugins]]
package = "@netlify/plugin-nextjs"

View File

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
@ -19,6 +19,7 @@
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"serwist": "^9.5.6",
"uuid": "^13.0.0",
"zod": "^4.3.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,80 @@
#!/usr/bin/env node
import fs from 'fs';
import zlib from 'zlib';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dir = path.join(__dirname, '..', 'public', 'icons');
fs.mkdirSync(dir, { recursive: true });
function crc32(data) {
let crc = 0xffffffff;
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c;
}
for (let i = 0; i < data.length; i++) crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const typeData = Buffer.concat([Buffer.from(type), data]);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(typeData));
return Buffer.concat([len, typeData, crcBuf]);
}
function makePNG(w, h) {
const rowBytes = 1 + w * 3;
const buf = Buffer.alloc(rowBytes * h);
const cx = w / 2, cy = h / 2;
const outerR = Math.min(w, h) / 2 - 2;
const innerR = outerR * 0.65;
let offset = 0;
for (let y = 0; y < h; y++) {
buf[offset++] = 0; // filter none
for (let x = 0; x < w; x++) {
const dx = x - cx, dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= outerR && dist >= innerR) {
buf[offset++] = 90; buf[offset++] = 140; buf[offset++] = 255;
} else {
buf[offset++] = 6; buf[offset++] = 7; buf[offset++] = 10;
}
}
}
const compressed = zlib.deflateSync(buf.slice(0, offset));
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 2;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr),
chunk('IDAT', compressed),
chunk('IEND', Buffer.alloc(0))
]);
}
const icons = [
[192, 'icon-192.png'],
[512, 'icon-512.png'],
[512, 'icon-512-maskable.png'],
[180, 'apple-touch-icon.png'],
];
for (const [size, name] of icons) {
const png = makePNG(size, size);
fs.writeFileSync(path.join(dir, name), png);
console.log(`Created ${name} (${png.length} bytes)`);
}
console.log('Done!');

View File

@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { ToastContainer } from "@/components/Toast";
@ -13,11 +13,18 @@ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
});
export const viewport: Viewport = {
themeColor: "#5A8CFF",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata: Metadata = {
title: "ChronoMind — Smart Pre-Warning Timer",
description: "AI-powered time awareness layer with pre-warning cascades, visual timeline, and Pomodoro sessions.",
manifest: "/manifest.json",
themeColor: "#5A8CFF",
appleWebApp: {
capable: true,
title: "ChronoMind",
@ -26,6 +33,15 @@ export const metadata: Metadata = {
other: {
"mobile-web-app-capable": "yes",
},
icons: {
icon: [
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [
{ url: "/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
},
};
export default function RootLayout({

View File

@ -11,15 +11,18 @@ import { CreateTimerModal } from './CreateTimerModal';
import { AlarmOverlay } from './AlarmOverlay';
import { requestNotificationPermission } from '@/lib/notifications';
import { formatTime, formatDate } from '@/lib/format';
import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye } from 'lucide-react';
import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye, BarChart3 } from 'lucide-react';
import { BUILT_IN_CATEGORIES, matchesCategory } from '@/lib/categories';
import Link from 'next/link';
import { FeedbackButton } from './FeedbackButton';
import { InstallPrompt } from './InstallPrompt';
import { useTheme } from '@/lib/use-theme';
export function Dashboard() {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [mounted, setMounted] = useState(false);
const [filterCategory, setFilterCategory] = useState<string | null>(null);
const timers = useTimerStore((s) => s.timers);
const now = useTimerStore((s) => s.now);
const { pause, resume } = useTimerStore();
@ -66,11 +69,12 @@ export function Dashboard() {
);
}
const activeTimers = timers.filter((t) =>
['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state)
);
const activeTimers = timers
.filter((t) => ['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state))
.filter((t) => matchesCategory(t.category, filterCategory));
const completedTimers = timers
.filter((t) => ['dismissed', 'completed'].includes(t.state))
.filter((t) => matchesCategory(t.category, filterCategory))
.slice(-10)
.reverse();
@ -105,6 +109,15 @@ export function Dashboard() {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
{/* Skip to content */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-semibold"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
Skip to content
</a>
{/* Alarm overlay for firing timers */}
<AlarmOverlay />
@ -132,6 +145,7 @@ export function Dashboard() {
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
@ -140,14 +154,25 @@ export function Dashboard() {
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--cm-text-tertiary)' }}
title="Focus Mode"
aria-label="Focus Mode"
>
<Eye size={18} />
</Link>
<Link
href="/history"
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--cm-text-tertiary)' }}
title="History & Stats"
aria-label="History & Stats"
>
<BarChart3 size={18} />
</Link>
<Link
href="/settings"
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--cm-text-tertiary)' }}
title="Settings"
aria-label="Settings"
>
<Settings size={18} />
</Link>
@ -156,6 +181,7 @@ export function Dashboard() {
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
title="Keyboard shortcuts (?)"
aria-label="Keyboard shortcuts"
>
<Keyboard size={18} />
</button>
@ -199,12 +225,40 @@ export function Dashboard() {
)}
{/* Main content */}
<main className="max-w-3xl mx-auto px-4 py-6">
<main id="main-content" className="max-w-3xl mx-auto px-4 py-6">
{/* Quick timer bar */}
<div className="mb-6">
<div className="mb-4">
<QuickTimerBar />
</div>
{/* Category filter */}
<div className="flex gap-1.5 mb-6 overflow-x-auto pb-1">
<button
onClick={() => setFilterCategory(null)}
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors cursor-pointer"
style={{
backgroundColor: !filterCategory ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
color: !filterCategory ? '#fff' : 'var(--cm-text-tertiary)',
}}
>
All
</button>
{BUILT_IN_CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => setFilterCategory(filterCategory === cat.id ? null : cat.id)}
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors cursor-pointer"
style={{
backgroundColor: filterCategory === cat.id ? `${cat.color}25` : 'var(--cm-surface-muted)',
color: filterCategory === cat.id ? cat.color : 'var(--cm-text-tertiary)',
border: filterCategory === cat.id ? `1px solid ${cat.color}50` : '1px solid transparent',
}}
>
{cat.label}
</button>
))}
</div>
{/* Active timers */}
{activeTimers.length > 0 ? (
<div className="space-y-3">
@ -260,6 +314,11 @@ export function Dashboard() {
{/* Feedback button */}
<FeedbackButton />
{/* Install prompt */}
<div className="max-w-3xl mx-auto">
<InstallPrompt />
</div>
{/* Footer */}
<footer className="max-w-3xl mx-auto px-4 py-8 text-center">
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>

View File

@ -0,0 +1,105 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Download, X } from 'lucide-react';
import { trackPWAInstalled, trackPWAInstallDismissed } from '@/lib/analytics';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(false);
const [installed, setInstalled] = useState(false);
useEffect(() => {
// Check if already dismissed this session
if (sessionStorage.getItem('cm-install-dismissed') === '1') {
setDismissed(true);
}
// Check if running as installed PWA
if (window.matchMedia('(display-mode: standalone)').matches) {
setInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
const installedHandler = () => {
setInstalled(true);
setDeferredPrompt(null);
trackPWAInstalled();
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', installedHandler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', installedHandler);
};
}, []);
const handleInstall = useCallback(async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
trackPWAInstalled();
} else {
trackPWAInstallDismissed();
}
setDeferredPrompt(null);
}, [deferredPrompt]);
const handleDismiss = useCallback(() => {
setDismissed(true);
sessionStorage.setItem('cm-install-dismissed', '1');
trackPWAInstallDismissed();
}, []);
// Don't show if: no prompt available, already dismissed, or already installed
if (!deferredPrompt || dismissed || installed) return null;
return (
<div
className="mx-4 mb-4 flex items-center gap-3 rounded-xl border px-4 py-3"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
}}
role="banner"
aria-label="Install ChronoMind app"
>
<Download size={20} style={{ color: 'var(--cm-accent)', flexShrink: 0 }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>
Install ChronoMind
</p>
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Add to home screen for offline access
</p>
</div>
<button
onClick={handleInstall}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors cursor-pointer whitespace-nowrap"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
Install
</button>
<button
onClick={handleDismiss}
className="p-1 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
aria-label="Dismiss install prompt"
>
<X size={16} />
</button>
</div>
);
}

44
web/src/lib/analytics.ts Normal file
View File

@ -0,0 +1,44 @@
// ── Analytics Stub ───────────────────────────────────────────────
// Console-logs in dev, no-op in prod. Easy to swap in Plausible or
// platform-service telemetry later.
type AnalyticsEvent =
| { name: 'timer_created'; props: { type: 'alarm' | 'countdown' | 'pomodoro'; urgency: string; cascade: string } }
| { name: 'timer_completed'; props: { type: string; durationMs: number } }
| { name: 'cascade_fired'; props: { timerId: string; minutesBefore: number } }
| { name: 'pomodoro_completed'; props: { rounds: number; totalMinutes: number } }
| { name: 'pwa_installed'; props: Record<string, never> }
| { name: 'pwa_install_dismissed'; props: Record<string, never> };
const isDev = process.env.NODE_ENV === 'development';
function track(event: AnalyticsEvent): void {
if (isDev) {
console.log(`[analytics] ${event.name}`, event.props);
}
// Future: send to Plausible, platform-service telemetry, etc.
}
export function trackTimerCreated(type: 'alarm' | 'countdown' | 'pomodoro', urgency: string, cascade: string): void {
track({ name: 'timer_created', props: { type, urgency, cascade } });
}
export function trackTimerCompleted(type: string, durationMs: number): void {
track({ name: 'timer_completed', props: { type, durationMs } });
}
export function trackCascadeFired(timerId: string, minutesBefore: number): void {
track({ name: 'cascade_fired', props: { timerId, minutesBefore } });
}
export function trackPomodoroCompleted(rounds: number, totalMinutes: number): void {
track({ name: 'pomodoro_completed', props: { rounds, totalMinutes } });
}
export function trackPWAInstalled(): void {
track({ name: 'pwa_installed', props: {} });
}
export function trackPWAInstallDismissed(): void {
track({ name: 'pwa_install_dismissed', props: {} });
}

View File

@ -18,6 +18,7 @@ import {
import { checkWarnings } from './cascade';
import { playAlarmSound, playWarningChime } from './sounds';
import { sendFireNotification, sendWarningNotification } from './notifications';
import { trackTimerCreated, trackTimerCompleted, trackCascadeFired } from './analytics';
export interface TimerStore {
timers: Timer[];
@ -60,18 +61,21 @@ export const useTimerStore = create<TimerStore>()(
addAlarm: (params) => {
const timer = createAlarm(params);
set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('alarm', timer.urgency, timer.cascade?.preset ?? 'none');
return timer;
},
addCountdown: (params) => {
const timer = createCountdown(params);
set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('countdown', timer.urgency, timer.cascade?.preset ?? 'none');
return timer;
},
addPomodoro: (params) => {
const timer = createPomodoro(params);
set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('pomodoro', timer.urgency, 'none');
return timer;
},
@ -100,6 +104,10 @@ export const useTimerStore = create<TimerStore>()(
},
complete: (id) => {
const timer = get().timers.find((t) => t.id === id);
if (timer) {
trackTimerCompleted(timer.type, timer.targetTime - timer.createdAt);
}
set((s) => ({ timers: updateTimer(s.timers, id, completeTimer) }));
},
@ -137,6 +145,7 @@ export const useTimerStore = create<TimerStore>()(
if (firedWarning) {
playWarningChime(timer.urgency);
sendWarningNotification(timer.label, firedWarning.minutesBefore, timer.urgency, timer.id);
trackCascadeFired(timer.id, firedWarning.minutesBefore);
}
// If any warning fired, update state to 'warning' if still active
if (timer.state === 'active') {