feat: add time blindness aids, feedback button, tab title flash, system theme detection
This commit is contained in:
parent
b39652accf
commit
d2b5563414
123
ios/ChronoMind/Shared/TimerEngine/Cascade.swift
Normal file
123
ios/ChronoMind/Shared/TimerEngine/Cascade.swift
Normal 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"
|
||||
}
|
||||
137
ios/ChronoMind/Shared/TimerEngine/Urgency.swift
Normal file
137
ios/ChronoMind/Shared/TimerEngine/Urgency.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)' }}>
|
||||
|
||||
81
web/src/components/FeedbackButton.tsx
Normal file
81
web/src/components/FeedbackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
46
web/src/lib/time-blindness.ts
Normal file
46
web/src/lib/time-blindness.ts
Normal 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));
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user