learning_ai_clock/web/src/components/TimerCard.tsx
saravanakumardb1 d3b55a2135 feat(web): add custom pre-warning messages per timer
- Add customMessage field to Timer interface + all Create*Params
- Custom message input in CreateTimerModal (optional, non-pomodoro)
- TimerCard shows custom message, falls back to auto-generated prep tips
- 373/373 tests pass, 0 type errors
2026-02-28 13:37:42 -08:00

330 lines
12 KiB
TypeScript

'use client';
import { useTimerStore } from '@/lib/store';
import { getRemainingMs } from '@/lib/timer-engine';
import type { Timer } from '@/lib/timer-engine';
import { getUrgencyConfig } from '@/lib/urgency';
import { formatDuration, formatTime, formatDurationCompact } from '@/lib/format';
import { formatMinutesBefore } from '@/lib/cascade';
import {
Clock,
Pause,
Play,
X,
AlarmClock,
Timer as TimerIcon,
Coffee,
Bell,
BellOff,
Repeat,
Link2,
Tag,
} from 'lucide-react';
import { getTimeReferenceMs } from '@/lib/time-blindness';
import { getSuggestedPrepTime, shouldShowPrepWarning, formatPrepWarning } from '@/lib/prep-time';
interface TimerCardProps {
timer: Timer;
}
export function TimerCard({ timer }: TimerCardProps) {
const now = useTimerStore((s) => s.now);
const { pause, resume, dismiss, snooze, advancePom } = useTimerStore();
const urgencyConfig = getUrgencyConfig(timer.urgency);
const remaining = getRemainingMs(timer, now);
const typeIcon = {
alarm: <AlarmClock size={16} />,
countdown: <TimerIcon size={16} />,
pomodoro: <Coffee size={16} />,
event: <Clock size={16} />,
}[timer.type];
const stateLabel = {
idle: 'Idle',
active: 'Active',
warning: 'Warning',
firing: 'FIRING!',
snoozed: 'Snoozed',
dismissed: 'Dismissed',
completed: 'Done',
paused: 'Paused',
}[timer.state];
const isFiring = timer.state === 'firing';
const isPaused = timer.state === 'paused';
const isActive = ['active', 'warning', 'snoozed'].includes(timer.state);
const isDone = ['dismissed', 'completed'].includes(timer.state);
// Pomodoro label
const pomLabel = timer.pomodoroState
? timer.pomodoroState.isBreak || timer.pomodoroState.isLongBreak
? `Break`
: `Round ${timer.pomodoroState.currentRound}/${timer.pomodoroConfig?.rounds ?? 4}`
: null;
// Next warning
const nextWarning = timer.warnings.find((w) => !w.fired);
return (
<div
className={`relative rounded-xl border p-4 transition-all duration-200 ${
isFiring
? 'animate-pulse border-2'
: 'border'
}`}
style={{
backgroundColor: isFiring ? urgencyConfig.bgColor : 'var(--cm-surface-card)',
borderColor: isFiring ? urgencyConfig.color : 'var(--cm-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span style={{ color: urgencyConfig.color }}>{typeIcon}</span>
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
{timer.type.charAt(0).toUpperCase() + timer.type.slice(1)}
{pomLabel && ` · ${pomLabel}`}
</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: urgencyConfig.bgColor,
color: urgencyConfig.color,
}}
>
{urgencyConfig.label}
</span>
{timer.category && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex items-center gap-1"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
>
<Tag size={10} /> {timer.category}
</span>
)}
{timer.recurringTimerId && (
<span
className="text-xs px-1.5 py-0.5 rounded-full flex items-center gap-0.5"
style={{ backgroundColor: 'rgba(46,230,214,0.15)', color: 'var(--cm-accent-secondary)' }}
title="Recurring timer"
>
<Repeat size={10} />
</span>
)}
{timer.linkedTimerId && (
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(90,140,255,0.15)', color: 'var(--cm-accent)' }}
title="Part of a chain"
>
<Link2 size={12} />
</span>
)}
</div>
<span
className="text-xs font-mono px-2 py-0.5 rounded"
style={{
backgroundColor: isFiring ? urgencyConfig.color : 'var(--cm-surface-muted)',
color: isFiring ? '#fff' : 'var(--cm-text-tertiary)',
}}
>
{stateLabel}
</span>
</div>
{/* Label */}
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--cm-text-primary)' }}>
{timer.label}
</h3>
{/* Countdown / Time */}
{!isDone && timer.type === 'event' && (
<div className="mb-3">
{/* Large days display for event countdowns */}
{(() => {
const days = Math.floor(remaining / 86_400_000);
const hours = Math.floor((remaining % 86_400_000) / 3_600_000);
const totalDays = timer.duration ? Math.ceil(timer.duration / 86_400_000) : days;
const progress = totalDays > 0 ? Math.max(0, Math.min(1, 1 - days / totalDays)) : 0;
return (
<>
<div className="flex items-baseline gap-2">
<span
className="text-4xl font-mono font-bold tabular-nums tracking-tight"
style={{ color: days <= 1 ? urgencyConfig.color : 'var(--cm-accent)' }}
>
{days}
</span>
<span className="text-lg font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
day{days !== 1 ? 's' : ''}
</span>
<span className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
{hours}h remaining
</span>
</div>
{/* Milestone progress bar */}
<div className="mt-2 h-2 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${progress * 100}%`,
backgroundColor: progress > 0.9 ? 'var(--cm-danger)' : progress > 0.7 ? 'var(--cm-warning)' : 'var(--cm-accent)',
}}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>Created</span>
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
{new Date(timer.targetTime).toLocaleDateString()}
</span>
</div>
</>
);
})()}
</div>
)}
{!isDone && timer.type !== 'event' && (
<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>
)}
{/* Cascade warnings progress */}
{timer.warnings.length > 0 && !isDone && (
<div className="flex gap-1 mb-3">
{timer.warnings.map((w) => (
<div
key={w.id}
className="flex-1 h-1.5 rounded-full transition-colors"
style={{
backgroundColor: w.fired ? urgencyConfig.color : 'var(--cm-surface-muted)',
opacity: w.fired ? 1 : 0.3,
}}
title={`${formatMinutesBefore(w.minutesBefore)} before — ${w.fired ? 'fired' : 'pending'}`}
/>
))}
</div>
)}
{/* Next warning */}
{nextWarning && !isDone && !isFiring && (
<p className="text-xs mb-3 flex items-center gap-1" style={{ color: 'var(--cm-text-tertiary)' }}>
<Bell size={12} />
Next warning: {formatMinutesBefore(nextWarning.minutesBefore)} before
</p>
)}
{/* Custom message or prep time warning */}
{!isDone && !isFiring && timer.customMessage && (
<p className="text-xs mb-3 flex items-center gap-1 font-medium" style={{ color: 'var(--cm-accent-secondary)' }}>
💬 {timer.customMessage}
</p>
)}
{!isDone && !isFiring && !timer.customMessage && (() => {
const prepConfig = getSuggestedPrepTime(timer.label, timer.category);
const minutesUntil = Math.round(remaining / 60_000);
if (shouldShowPrepWarning(timer.targetTime, prepConfig, now)) {
return (
<p className="text-xs mb-3 flex items-center gap-1 font-medium" style={{ color: 'var(--cm-warning)' }}>
{formatPrepWarning(prepConfig, minutesUntil)}
</p>
);
}
return null;
})()}
{/* Snooze info */}
{timer.snoozeCount > 0 && (
<p className="text-xs mb-3 flex items-center gap-1" style={{ color: 'var(--cm-text-tertiary)' }}>
<BellOff size={12} />
Snoozed {timer.snoozeCount}x
</p>
)}
{/* Actions */}
<div className="flex gap-2">
{isActive && timer.type !== 'alarm' && (
<button
onClick={() => pause(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-muted)',
color: 'var(--cm-text-secondary)',
}}
>
<Pause size={14} /> Pause
</button>
)}
{isPaused && (
<button
onClick={() => resume(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-accent)',
color: '#fff',
}}
>
<Play size={14} /> Resume
</button>
)}
{isFiring && (
<>
<button
onClick={() => snooze(timer.id, 5)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
5m
</button>
<button
onClick={() => snooze(timer.id, 15)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
15m
</button>
{timer.type === 'pomodoro' && (
<button
onClick={() => advancePom(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent-secondary)', color: '#000' }}
>
Next
</button>
)}
</>
)}
{(isActive || isPaused || isFiring) && (
<button
onClick={() => dismiss(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ml-auto"
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
>
<X size={14} /> Dismiss
</button>
)}
</div>
</div>
);
}