330 lines
12 KiB
TypeScript
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: 'var(--cm-accent-secondary-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: 'var(--cm-accent-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 ? 'var(--cm-white)' : '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: 'var(--cm-white)',
|
|
}}
|
|
>
|
|
<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: 'var(--cm-black)' }}
|
|
>
|
|
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: 'var(--cm-critical-15)', color: 'var(--cm-danger)' }}
|
|
>
|
|
<X size={14} /> Dismiss
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|