diff options
| author | Solstice <solstice@local> | 2026-06-09 00:22:18 -0700 |
|---|---|---|
| committer | Solstice <solstice@local> | 2026-06-09 00:22:18 -0700 |
| commit | 6cb006cc136f1fc5c83537cc30c64d223d1755e4 (patch) | |
| tree | b12dfb4a8345c980dc8747657ce381016cdf3b34 /src/components/TimerView.tsx | |
| parent | 25e1dcf205cd14feafdd9b4cf6b7a66f253ba6d2 (diff) | |
feat: frontend view, state management, and user interface
Diffstat (limited to 'src/components/TimerView.tsx')
| -rw-r--r-- | src/components/TimerView.tsx | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/src/components/TimerView.tsx b/src/components/TimerView.tsx new file mode 100644 index 0000000..b56e6a8 --- /dev/null +++ b/src/components/TimerView.tsx @@ -0,0 +1,236 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useTimerStore, TimerPhase } from '../store/timerStore'; +import { useTaskStore } from '../store/taskStore'; + +const RING_SIZE = 280; +const STROKE = 8; +const RADIUS = (RING_SIZE - STROKE) / 2; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +function phaseLabel(phase: TimerPhase): string { + switch (phase) { + case 'work': return 'Focus'; + case 'short_break': return 'Short Break'; + case 'long_break': return 'Long Break'; + } +} + +function phaseColor(phase: TimerPhase): string { + switch (phase) { + case 'work': return 'var(--brass)'; + case 'short_break': return 'var(--positive)'; + case 'long_break': return 'var(--info)'; + } +} + +function eyebrowText(phase: TimerPhase, sessionCount: number): string { + if (phase === 'work') { + return `WORK ยท SESSION ${sessionCount + 1}`; + } + if (phase === 'short_break') return 'SHORT BREAK'; + return 'LONG BREAK'; +} + +function formatTime(secs: number): string { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +export function TimerView() { + const { phase, remainingSecs, totalSecs, running, sessionCount, currentTaskId } = + useTimerStore(); + const tasks = useTaskStore((s) => s.tasks); + + const currentTask = tasks.find((t) => t.id === currentTaskId) ?? null; + + const progress = totalSecs > 0 ? remainingSecs / totalSecs : 1; + const dashOffset = CIRCUMFERENCE * (1 - progress); + const arcColor = phaseColor(phase); + + const handleStart = () => invoke('start_timer'); + const handlePause = () => invoke('pause_timer'); + const handleSkip = () => invoke('skip_phase'); + const handleReset = () => invoke('reset_timer'); + + return ( + <div + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '24px', + flex: 1, + padding: '32px', + }} + > + {/* Eyebrow */} + <span className="eyebrow" style={{ letterSpacing: '0.18em' }}> + {eyebrowText(phase, sessionCount)} + </span> + + {/* Circular ring */} + <div style={{ position: 'relative', width: RING_SIZE, height: RING_SIZE }}> + <svg + width={RING_SIZE} + height={RING_SIZE} + style={{ transform: 'rotate(-90deg)' }} + > + {/* Background track */} + <circle + cx={RING_SIZE / 2} + cy={RING_SIZE / 2} + r={RADIUS} + fill="none" + stroke="var(--line-2)" + strokeWidth={STROKE} + /> + {/* Progress arc */} + <circle + cx={RING_SIZE / 2} + cy={RING_SIZE / 2} + r={RADIUS} + fill="none" + stroke={arcColor} + strokeWidth={STROKE} + strokeLinecap="round" + strokeDasharray={CIRCUMFERENCE} + strokeDashoffset={dashOffset} + style={{ transition: 'stroke-dashoffset 0.8s ease, stroke 0.3s ease' }} + /> + </svg> + + {/* Center content */} + <div + style={{ + position: 'absolute', + inset: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + }} + > + <span + style={{ + fontFamily: 'var(--font-display)', + fontStyle: 'italic', + fontSize: '56px', + fontWeight: 400, + lineHeight: 1, + color: 'var(--fg-1)', + letterSpacing: '-0.02em', + fontVariantNumeric: 'tabular-nums', + }} + > + {formatTime(remainingSecs)} + </span> + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.12em', + color: 'var(--fg-3)', + }} + > + {phaseLabel(phase)} + </span> + </div> + </div> + + {/* Current task name */} + {currentTask && ( + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + color: 'var(--fg-3)', + maxWidth: '320px', + textAlign: 'center', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {currentTask.name} + </span> + )} + + {/* Controls */} + <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> + <button + onClick={running ? handlePause : handleStart} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + fontWeight: 600, + padding: '8px 24px', + borderRadius: 'var(--r-1)', + border: 'none', + background: 'var(--brass)', + color: 'var(--brass-ink)', + cursor: 'pointer', + transition: 'background 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)'; + }} + onMouseDown={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-deep)'; + }} + onMouseUp={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + > + {running ? 'Pause' : 'Start'} + </button> + + <GhostButton onClick={handleSkip}>Skip</GhostButton> + <GhostButton onClick={handleReset}>Reset</GhostButton> + </div> + </div> + ); +} + +function GhostButton({ + onClick, + children, +}: { + onClick: () => void; + children: React.ReactNode; +}) { + return ( + <button + onClick={onClick} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + fontWeight: 500, + padding: '8px 16px', + borderRadius: 'var(--r-1)', + border: '1px solid var(--line-2)', + background: 'transparent', + color: 'var(--brass)', + cursor: 'pointer', + transition: 'border-color 0.15s ease, color 0.15s ease', + }} + onMouseEnter={(e) => { + const el = e.currentTarget as HTMLButtonElement; + el.style.borderColor = 'var(--brass)'; + }} + onMouseLeave={(e) => { + const el = e.currentTarget as HTMLButtonElement; + el.style.borderColor = 'var(--line-2)'; + }} + > + {children} + </button> + ); +} |
