summaryrefslogtreecommitdiff
path: root/src/components/TimerView.tsx
diff options
context:
space:
mode:
authorSolstice <solstice@local>2026-06-09 00:22:18 -0700
committerSolstice <solstice@local>2026-06-09 00:22:18 -0700
commit6cb006cc136f1fc5c83537cc30c64d223d1755e4 (patch)
treeb12dfb4a8345c980dc8747657ce381016cdf3b34 /src/components/TimerView.tsx
parent25e1dcf205cd14feafdd9b4cf6b7a66f253ba6d2 (diff)
feat: frontend view, state management, and user interface
Diffstat (limited to 'src/components/TimerView.tsx')
-rw-r--r--src/components/TimerView.tsx236
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>
+ );
+}