feat: add time blindness aids, feedback button, tab title flash, system theme detection

This commit is contained in:
saravanakumardb1 2026-02-27 21:08:22 -08:00
parent b39652accf
commit d2b5563414
7 changed files with 443 additions and 12 deletions

View File

@ -0,0 +1,123 @@
// Pre-Warning Cascade System
// Cascade presets aligned with PRD: Aggressive, Standard, Light, Minimal, None, Custom
// Ported from web/src/lib/cascade.ts
import Foundation
// MARK: - Cascade Preset
enum CascadePreset: String, Codable, CaseIterable, Identifiable {
case aggressive
case standard
case light
case minimal
case none
case custom
var id: String { rawValue }
var label: String {
switch self {
case .aggressive: return "Aggressive"
case .standard: return "Standard"
case .light: return "Light"
case .minimal: return "Minimal"
case .none: return "None (fire only)"
case .custom: return "Custom"
}
}
/// Default intervals in minutes before target time
var defaultIntervals: [Int] {
switch self {
case .aggressive: return [240, 180, 120, 90, 60, 30, 15, 5, 1]
case .standard: return [120, 60, 30, 15, 5]
case .light: return [60, 15, 5]
case .minimal: return [15]
case .none: return []
case .custom: return []
}
}
}
// MARK: - Cascade Warning
struct CascadeWarning: Codable, Identifiable, Equatable {
let id: String
let minutesBefore: Int
var fired: Bool
var firedAt: Date?
let scheduledTime: Date
static func == (lhs: CascadeWarning, rhs: CascadeWarning) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Cascade Config
struct CascadeConfig: Codable {
let preset: CascadePreset
let intervals: [Int] // minutes before target time (for custom preset)
}
// MARK: - Cascade Functions
/// Calculate all warning timestamps from target time and cascade intervals.
/// Filters out warnings that would be in the past relative to `now`.
func calculateCascadeWarnings(
targetTime: Date,
intervals: [Int],
now: Date = Date()
) -> [CascadeWarning] {
let sorted = intervals.sorted(by: >) // largest first (earliest warning)
return sorted.enumerated().map { idx, minutesBefore in
let scheduledTime = targetTime.addingTimeInterval(-Double(minutesBefore) * 60.0)
return CascadeWarning(
id: "w-\(idx)-\(minutesBefore)m",
minutesBefore: minutesBefore,
fired: scheduledTime <= now,
firedAt: scheduledTime <= now ? scheduledTime : nil,
scheduledTime: scheduledTime
)
}
}
/// Get the next unfired warning from a cascade.
func getNextWarning(_ warnings: [CascadeWarning]) -> CascadeWarning? {
warnings.first(where: { !$0.fired })
}
/// Check which warnings should fire given the current time.
/// Returns newly-fired warning IDs and mutates the warnings array.
@discardableResult
func checkWarnings(_ warnings: inout [CascadeWarning], now: Date = Date()) -> [String] {
var newlyFired: [String] = []
for i in warnings.indices {
if !warnings[i].fired && warnings[i].scheduledTime <= now {
warnings[i].fired = true
warnings[i].firedAt = now
newlyFired.append(warnings[i].id)
}
}
return newlyFired
}
/// Get intervals for a preset, or custom intervals if preset is 'custom'.
func getCascadeIntervals(_ config: CascadeConfig) -> [Int] {
if config.preset == .custom {
return config.intervals.sorted(by: >)
}
return config.preset.defaultIntervals
}
/// Format minutes into human-readable string.
func formatMinutesBefore(_ minutes: Int) -> String {
if minutes >= 60 {
let hours = minutes / 60
let remaining = minutes % 60
if remaining == 0 { return "\(hours)h" }
return "\(hours)h \(remaining)m"
}
return "\(minutes)m"
}

View File

@ -0,0 +1,137 @@
// Urgency System
// 5 levels mapping to notification style, sound, vibration, visual intensity, snooze behavior
// Ported from web/src/lib/urgency.ts
import SwiftUI
// MARK: - Urgency Level
enum UrgencyLevel: String, Codable, CaseIterable, Identifiable {
case critical
case important
case standard
case gentle
case passive
var id: String { rawValue }
}
// MARK: - Notification Style
enum NotificationStyle: String, Codable {
case persistent // Critical: won't auto-dismiss
case prominent // Important: sound + banner
case `default` // Standard: normal notification
case subtle // Gentle: silent banner
case badge // Passive: badge only
}
// MARK: - Urgency Configuration
struct UrgencyConfig {
let level: UrgencyLevel
let label: String
let color: Color
let colorHex: String
let notificationStyle: NotificationStyle
let soundEnabled: Bool
let vibrationPattern: [Int] // ms on/off pattern
let visualIntensity: Double // 0-1
let autoSnoozeMinutes: Int? // nil = no auto-snooze
let requireConfirmToDismiss: Bool
let fullScreenOverlay: Bool
}
// MARK: - Urgency Configs
let urgencyConfigs: [UrgencyLevel: UrgencyConfig] = [
.critical: UrgencyConfig(
level: .critical,
label: "Critical",
color: Color(hex: 0xFF4757),
colorHex: "#FF4757",
notificationStyle: .persistent,
soundEnabled: true,
vibrationPattern: [200, 100, 200, 100, 400],
visualIntensity: 1.0,
autoSnoozeMinutes: nil,
requireConfirmToDismiss: true,
fullScreenOverlay: true
),
.important: UrgencyConfig(
level: .important,
label: "Important",
color: Color(hex: 0xFF9F43),
colorHex: "#FF9F43",
notificationStyle: .prominent,
soundEnabled: true,
vibrationPattern: [200, 100, 200],
visualIntensity: 0.8,
autoSnoozeMinutes: 10,
requireConfirmToDismiss: false,
fullScreenOverlay: false
),
.standard: UrgencyConfig(
level: .standard,
label: "Standard",
color: Color(hex: 0xFECA57),
colorHex: "#FECA57",
notificationStyle: .default,
soundEnabled: true,
vibrationPattern: [200],
visualIntensity: 0.6,
autoSnoozeMinutes: 5,
requireConfirmToDismiss: false,
fullScreenOverlay: false
),
.gentle: UrgencyConfig(
level: .gentle,
label: "Gentle",
color: Color(hex: 0x2ED573),
colorHex: "#2ED573",
notificationStyle: .subtle,
soundEnabled: true,
vibrationPattern: [100],
visualIntensity: 0.3,
autoSnoozeMinutes: 5,
requireConfirmToDismiss: false,
fullScreenOverlay: false
),
.passive: UrgencyConfig(
level: .passive,
label: "Passive",
color: Color(hex: 0xA5B1C7),
colorHex: "#A5B1C7",
notificationStyle: .badge,
soundEnabled: false,
vibrationPattern: [],
visualIntensity: 0.1,
autoSnoozeMinutes: nil,
requireConfirmToDismiss: false,
fullScreenOverlay: false
),
]
let urgencyOrder: [UrgencyLevel] = [.critical, .important, .standard, .gentle, .passive]
func getUrgencyConfig(_ level: UrgencyLevel) -> UrgencyConfig {
urgencyConfigs[level]!
}
func getUrgencyIndex(_ level: UrgencyLevel) -> Int {
urgencyOrder.firstIndex(of: level) ?? 0
}
// MARK: - Color Extension
extension Color {
init(hex: UInt, alpha: Double = 1.0) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255.0,
green: Double((hex >> 8) & 0xFF) / 255.0,
blue: Double(hex & 0xFF) / 255.0,
opacity: alpha
)
}
}

View File

@ -12,6 +12,7 @@ import { AlarmOverlay } from './AlarmOverlay';
import { requestNotificationPermission } from '@/lib/notifications';
import { formatTime, formatDate } from '@/lib/format';
import { Plus, Clock, Bell, Keyboard, Sun, Moon } from 'lucide-react';
import { FeedbackButton } from './FeedbackButton';
import { useTheme } from '@/lib/use-theme';
export function Dashboard() {
@ -72,8 +73,21 @@ export function Dashboard() {
.slice(-10)
.reverse();
// Tab title update
// Tab title update with flash on fire
const hasFiring = timers.some((t) => t.state === 'firing');
useEffect(() => {
if (hasFiring) {
// Flash between alarm text and timer label
const firingTimer = timers.find((t) => t.state === 'firing');
const label = firingTimer?.label ?? 'Timer';
let flash = true;
const interval = setInterval(() => {
document.title = flash ? `🔔 TIME! — ${label}` : `${label} | ChronoMind`;
flash = !flash;
}, 800);
return () => clearInterval(interval);
}
const next = activeTimers
.filter((t) => ['active', 'warning'].includes(t.state))
.sort((a, b) => a.targetTime - b.targetTime)[0];
@ -86,7 +100,7 @@ export function Dashboard() {
} else {
document.title = 'ChronoMind — Smart Pre-Warning Timer';
}
}, [now, activeTimers]);
}, [now, activeTimers, hasFiring, timers]);
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
@ -226,6 +240,9 @@ export function Dashboard() {
{/* Create Timer Modal */}
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
{/* Feedback button */}
<FeedbackButton />
{/* 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,81 @@
'use client';
import { useState } from 'react';
import { MessageSquare, Bug, X } from 'lucide-react';
export function FeedbackButton() {
const [isOpen, setIsOpen] = useState(false);
const browserInfo = typeof navigator !== 'undefined'
? `${navigator.userAgent}\nScreen: ${screen.width}x${screen.height}\nURL: ${window.location.href}`
: '';
const feedbackUrl = `https://github.com/saravanakumardb1/learning_ai_clock/issues/new?template=feedback.md&title=[Feedback]%20`;
const bugUrl = `https://github.com/saravanakumardb1/learning_ai_clock/issues/new?template=bug_report.md&title=[Bug]%20&body=${encodeURIComponent(`\n\n---\nBrowser Info:\n\`\`\`\n${browserInfo}\n\`\`\``)}`;
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 z-30 p-3 rounded-full shadow-lg transition-all cursor-pointer hover:scale-110"
style={{
backgroundColor: 'var(--cm-accent)',
color: '#fff',
}}
title="Send feedback"
>
<MessageSquare size={20} />
</button>
);
}
return (
<div
className="fixed bottom-6 right-6 z-30 rounded-xl border shadow-2xl p-4 w-64"
style={{
backgroundColor: 'var(--cm-bg-elevated)',
borderColor: 'var(--cm-border)',
}}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
Feedback
</span>
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<X size={16} />
</button>
</div>
<div className="space-y-2">
<a
href={feedbackUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
backgroundColor: 'var(--cm-surface-card)',
color: 'var(--cm-text-secondary)',
}}
>
<MessageSquare size={16} /> Send Feedback
</a>
<a
href={bugUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
backgroundColor: 'rgba(255,71,87,0.1)',
color: 'var(--cm-danger)',
}}
>
<Bug size={16} /> Report Bug
</a>
</div>
</div>
);
}

View File

@ -17,6 +17,7 @@ import {
Bell,
BellOff,
} from 'lucide-react';
import { getTimeReferenceMs } from '@/lib/time-blindness';
interface TimerCardProps {
timer: Timer;
@ -109,16 +110,24 @@ export function TimerCard({ timer }: TimerCardProps) {
{/* Countdown / Time */}
{!isDone && (
<div className="flex items-baseline gap-2 mb-3">
<span
className="text-3xl font-mono font-bold tabular-nums tracking-tight"
style={{ color: isFiring ? urgencyConfig.color : 'var(--cm-text-primary)' }}
>
{formatDuration(remaining)}
</span>
<span className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
fires at {formatTime(timer.targetTime)}
</span>
<div className="mb-3">
<div className="flex items-baseline gap-2">
<span
className="text-3xl font-mono font-bold tabular-nums tracking-tight"
style={{ color: isFiring ? urgencyConfig.color : 'var(--cm-text-primary)' }}
>
{formatDuration(remaining)}
</span>
<span className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
fires at {formatTime(timer.targetTime)}
</span>
</div>
{/* Time blindness aid */}
{remaining > 60_000 && (
<p className="text-xs mt-1 italic" style={{ color: 'var(--cm-text-tertiary)' }}>
{getTimeReferenceMs(remaining)}
</p>
)}
</div>
)}

View File

@ -0,0 +1,46 @@
// ── Time Blindness Aids ────────────────────────────────────────
// "This is about as long as [familiar reference]"
interface TimeReference {
maxMinutes: number;
label: string;
}
const TIME_REFERENCES: TimeReference[] = [
{ maxMinutes: 1, label: 'a deep breath' },
{ maxMinutes: 2, label: 'brushing your teeth' },
{ maxMinutes: 3, label: 'making instant coffee' },
{ maxMinutes: 5, label: 'a short walk around the block' },
{ maxMinutes: 10, label: 'a quick shower' },
{ maxMinutes: 15, label: 'a coffee break' },
{ maxMinutes: 20, label: 'a short podcast episode' },
{ maxMinutes: 25, label: 'one Pomodoro session' },
{ maxMinutes: 30, label: 'a TV sitcom episode' },
{ maxMinutes: 45, label: 'a yoga class' },
{ maxMinutes: 60, label: 'one hour-long meeting' },
{ maxMinutes: 90, label: 'a movie' },
{ maxMinutes: 120, label: 'a long movie or flight' },
{ maxMinutes: 180, label: 'a half-day workshop' },
{ maxMinutes: 240, label: 'a road trip playlist' },
{ maxMinutes: 480, label: 'a full work day' },
];
/**
* Get a familiar time reference for a given duration.
* e.g., "About as long as a TV sitcom episode"
*/
export function getTimeReference(minutes: number): string | null {
if (minutes <= 0) return null;
const ref = TIME_REFERENCES.find((r) => minutes <= r.maxMinutes);
if (!ref) return null;
return `About as long as ${ref.label}`;
}
/**
* Get time reference for milliseconds.
*/
export function getTimeReferenceMs(ms: number): string | null {
return getTimeReference(Math.round(ms / 60_000));
}

View File

@ -13,7 +13,25 @@ export function useTheme() {
if (stored) {
setTheme(stored);
applyTheme(stored);
} else {
// Detect system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const systemTheme: Theme = prefersDark ? 'dark' : 'light';
setTheme(systemTheme);
applyTheme(systemTheme);
}
// Listen for system changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('chronomind-theme')) {
const next: Theme = e.matches ? 'dark' : 'light';
setTheme(next);
applyTheme(next);
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
const toggle = () => {