summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx113
-rw-r--r--src/components/NotificationOverlay.tsx117
-rw-r--r--src/components/SettingsPanel.tsx240
-rw-r--r--src/components/TaskList.tsx283
-rw-r--r--src/components/TimerView.tsx236
-rw-r--r--src/hooks/useTimerEvents.ts78
-rw-r--r--src/store/settingsStore.ts33
-rw-r--r--src/store/taskStore.ts80
-rw-r--r--src/store/timerStore.ts42
9 files changed, 1217 insertions, 5 deletions
diff --git a/src/App.tsx b/src/App.tsx
index a6e3249..966e8e1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,114 @@
+import { useEffect, useState, useCallback } from 'react';
+import { TimerView } from './components/TimerView';
+import { TaskList } from './components/TaskList';
+import { SettingsPanel } from './components/SettingsPanel';
+import { NotificationOverlay } from './components/NotificationOverlay';
+import { useTimerEvents } from './hooks/useTimerEvents';
+import { useTaskStore } from './store/taskStore';
+import { useSettingsStore } from './store/settingsStore';
+
+function GearIcon() {
+ return (
+ <svg
+ width="18"
+ height="18"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <circle cx="12" cy="12" r="3" />
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
+ </svg>
+ );
+}
+
function App() {
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [notifVisible, setNotifVisible] = useState(false);
+ const [notifTaskId, setNotifTaskId] = useState<string | null>(null);
+
+ const fetchTasks = useTaskStore((s) => s.fetchTasks);
+ const fetchSettings = useSettingsStore((s) => s.fetchSettings);
+
+ // Bootstrap data on mount
+ useEffect(() => {
+ fetchTasks();
+ fetchSettings();
+ }, [fetchTasks, fetchSettings]);
+
+ const handleCompleted = useCallback((taskId: string | null) => {
+ setNotifTaskId(taskId);
+ setNotifVisible(true);
+ }, []);
+
+ useTimerEvents(handleCompleted);
+
return (
- <main className="flex items-center justify-center min-h-screen">
- <div className="text-center">
- <h1 className="font-display text-fg-1 text-4xl mb-2">Solstice</h1>
- <p className="text-fg-3">Pomodoro timer with ambient soundscapes.</p>
+ <div
+ style={{
+ display: 'flex',
+ width: '100vw',
+ height: '100vh',
+ background: 'var(--ink-0)',
+ overflow: 'hidden',
+ position: 'relative',
+ }}
+ >
+ {/* Sidebar */}
+ <TaskList />
+
+ {/* Main area */}
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
+ {/* Top bar */}
+ <div
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ padding: '12px 16px',
+ borderBottom: '1px solid var(--line-1)',
+ }}
+ >
+ <button
+ onClick={() => setSettingsOpen(true)}
+ title="Settings"
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ padding: '6px',
+ border: 'none',
+ background: 'transparent',
+ color: 'var(--fg-3)',
+ cursor: 'pointer',
+ borderRadius: 'var(--r-2)',
+ transition: 'color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-1)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-3)';
+ }}
+ >
+ <GearIcon />
+ </button>
+ </div>
+
+ {/* Timer */}
+ <TimerView />
</div>
- </main>
+
+ {/* Overlays */}
+ <SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
+ <NotificationOverlay
+ visible={notifVisible}
+ taskId={notifTaskId}
+ onDismiss={() => setNotifVisible(false)}
+ />
+ </div>
);
}
diff --git a/src/components/NotificationOverlay.tsx b/src/components/NotificationOverlay.tsx
new file mode 100644
index 0000000..965f19d
--- /dev/null
+++ b/src/components/NotificationOverlay.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useRef } from 'react';
+import { sendNotification } from '@tauri-apps/plugin-notification';
+import { useTaskStore } from '../store/taskStore';
+
+interface NotificationOverlayProps {
+ visible: boolean;
+ taskId: string | null;
+ onDismiss: () => void;
+}
+
+export function NotificationOverlay({ visible, taskId, onDismiss }: NotificationOverlayProps) {
+ const tasks = useTaskStore((s) => s.tasks);
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+ const task = tasks.find((t) => t.id === taskId) ?? null;
+
+ useEffect(() => {
+ if (!visible) return;
+
+ // OS notification
+ const body = task ? `"${task.name}" session complete.` : 'Session complete. Time for a break.';
+ try {
+ sendNotification({ title: 'Solstice', body });
+ } catch {
+ // Notifications may not be permitted in dev; ignore errors
+ }
+
+ // Auto-dismiss after 4 seconds
+ timerRef.current = setTimeout(() => {
+ onDismiss();
+ }, 4000);
+
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, [visible, task, onDismiss]);
+
+ if (!visible) return null;
+
+ return (
+ <div
+ onClick={onDismiss}
+ style={{
+ position: 'fixed',
+ inset: 0,
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ paddingTop: '40px',
+ zIndex: 200,
+ pointerEvents: 'auto',
+ }}
+ >
+ <div
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ background: 'var(--ink-2)',
+ border: '1px solid var(--line-2)',
+ borderRadius: 'var(--r-3)',
+ boxShadow: 'var(--shadow-3)',
+ padding: '24px 32px',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '8px',
+ animation: 'slideDown 0.2s ease',
+ minWidth: '280px',
+ }}
+ >
+ <span
+ style={{
+ fontFamily: 'var(--font-display)',
+ fontStyle: 'italic',
+ fontSize: '28px',
+ fontWeight: 400,
+ color: 'var(--fg-1)',
+ lineHeight: 1.1,
+ }}
+ >
+ Session complete
+ </span>
+ {task && (
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '13px',
+ color: 'var(--fg-3)',
+ }}
+ >
+ {task.name}
+ </span>
+ )}
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '11px',
+ color: 'var(--fg-4)',
+ marginTop: '4px',
+ textTransform: 'uppercase',
+ letterSpacing: '0.1em',
+ }}
+ >
+ Click to dismiss
+ </span>
+ </div>
+
+ <style>{`
+ @keyframes slideDown {
+ from { opacity: 0; transform: translateY(-16px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ `}</style>
+ </div>
+ );
+}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
new file mode 100644
index 0000000..0d3cbf9
--- /dev/null
+++ b/src/components/SettingsPanel.tsx
@@ -0,0 +1,240 @@
+import { useState, useEffect } from 'react';
+import { useSettingsStore, Settings } from '../store/settingsStore';
+
+interface SettingsPanelProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export function SettingsPanel({ open, onClose }: SettingsPanelProps) {
+ const { settings, fetchSettings, updateSettings } = useSettingsStore();
+
+ const [workMins, setWorkMins] = useState(25);
+ const [shortBreakMins, setShortBreakMins] = useState(5);
+ const [longBreakMins, setLongBreakMins] = useState(15);
+ const [sessionsBeforeLong, setSessionsBeforeLong] = useState(4);
+
+ useEffect(() => {
+ if (open && !settings) {
+ fetchSettings();
+ }
+ }, [open, settings, fetchSettings]);
+
+ useEffect(() => {
+ if (settings) {
+ setWorkMins(Math.round(settings.work_duration_secs / 60));
+ setShortBreakMins(Math.round(settings.short_break_secs / 60));
+ setLongBreakMins(Math.round(settings.long_break_secs / 60));
+ setSessionsBeforeLong(settings.sessions_before_long_break);
+ }
+ }, [settings]);
+
+ if (!open) return null;
+
+ const handleSave = async () => {
+ const s: Settings = {
+ work_duration_secs: workMins * 60,
+ short_break_secs: shortBreakMins * 60,
+ long_break_secs: longBreakMins * 60,
+ sessions_before_long_break: sessionsBeforeLong,
+ };
+ await updateSettings(s);
+ onClose();
+ };
+
+ return (
+ <div
+ onClick={onClose}
+ style={{
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(11, 14, 19, 0.70)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 100,
+ }}
+ >
+ {/* Modal card */}
+ <div
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ background: 'var(--ink-1)',
+ border: '1px solid var(--line-2)',
+ borderRadius: 'var(--r-3)',
+ boxShadow: 'var(--shadow-3)',
+ padding: '32px',
+ width: '360px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '24px',
+ }}
+ >
+ <span className="eyebrow" style={{ letterSpacing: '0.18em' }}>
+ Settings
+ </span>
+
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
+ <NumberField
+ label="Work duration"
+ unit="min"
+ value={workMins}
+ min={1}
+ max={120}
+ onChange={setWorkMins}
+ />
+ <NumberField
+ label="Short break"
+ unit="min"
+ value={shortBreakMins}
+ min={1}
+ max={60}
+ onChange={setShortBreakMins}
+ />
+ <NumberField
+ label="Long break"
+ unit="min"
+ value={longBreakMins}
+ min={1}
+ max={60}
+ onChange={setLongBreakMins}
+ />
+ <NumberField
+ label="Sessions before long break"
+ unit="sessions"
+ value={sessionsBeforeLong}
+ min={1}
+ max={10}
+ onChange={setSessionsBeforeLong}
+ />
+ </div>
+
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
+ <button
+ onClick={onClose}
+ 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',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--brass)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--line-2)';
+ }}
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ fontWeight: 600,
+ padding: '8px 20px',
+ 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)';
+ }}
+ >
+ Save
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+interface NumberFieldProps {
+ label: string;
+ unit: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange: (v: number) => void;
+}
+
+function NumberField({ label, unit, value, min, max, onChange }: NumberFieldProps) {
+ return (
+ <div
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '16px',
+ }}
+ >
+ <label
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ color: 'var(--fg-2)',
+ flex: 1,
+ }}
+ >
+ {label}
+ </label>
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+ <input
+ type="number"
+ min={min}
+ max={max}
+ value={value}
+ onChange={(e) => onChange(Math.min(max, Math.max(min, parseInt(e.target.value) || min)))}
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '14px',
+ fontVariantNumeric: 'tabular-nums',
+ padding: '5px 8px',
+ borderRadius: 'var(--r-2)',
+ border: '1px solid var(--line-2)',
+ background: 'var(--ink-3)',
+ color: 'var(--fg-1)',
+ outline: 'none',
+ width: '56px',
+ textAlign: 'center',
+ }}
+ onFocus={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'var(--shadow-brass)';
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'none';
+ }}
+ />
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ color: 'var(--fg-4)',
+ minWidth: '44px',
+ }}
+ >
+ {unit}
+ </span>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx
new file mode 100644
index 0000000..0f87e0d
--- /dev/null
+++ b/src/components/TaskList.tsx
@@ -0,0 +1,283 @@
+import { useState } from 'react';
+import { useTaskStore, Task } from '../store/taskStore';
+import { useTimerStore } from '../store/timerStore';
+
+export function TaskList() {
+ const { tasks, addTask, deleteTask, setCurrentTask } = useTaskStore();
+ const currentTaskId = useTimerStore((s) => s.currentTaskId);
+
+ const [showForm, setShowForm] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [newSessions, setNewSessions] = useState(4);
+
+ const handleAdd = async () => {
+ const name = newName.trim();
+ if (!name) return;
+ await addTask(name, newSessions);
+ setNewName('');
+ setNewSessions(4);
+ setShowForm(false);
+ };
+
+ const handleSelect = async (task: Task) => {
+ if (task.id === currentTaskId) {
+ await setCurrentTask(null);
+ } else {
+ await setCurrentTask(task.id);
+ }
+ };
+
+ const handleDelete = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ await deleteTask(id);
+ };
+
+ return (
+ <aside
+ style={{
+ width: '280px',
+ flexShrink: 0,
+ background: 'var(--ink-1)',
+ borderRight: '1px solid var(--line-1)',
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Header */}
+ <div
+ style={{
+ padding: '16px',
+ borderBottom: '1px solid var(--line-1)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ }}
+ >
+ <span className="eyebrow">Tasks</span>
+ <button
+ onClick={() => setShowForm((v) => !v)}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ fontWeight: 600,
+ padding: '4px 10px',
+ borderRadius: 'var(--r-1)',
+ border: '1px solid var(--line-2)',
+ background: 'transparent',
+ color: 'var(--brass)',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--brass)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--line-2)';
+ }}
+ >
+ + Add
+ </button>
+ </div>
+
+ {/* Add task form */}
+ {showForm && (
+ <div
+ style={{
+ padding: '12px 16px',
+ borderBottom: '1px solid var(--line-1)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ }}
+ >
+ <input
+ type="text"
+ placeholder="Task name"
+ value={newName}
+ onChange={(e) => setNewName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ autoFocus
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ padding: '6px 10px',
+ borderRadius: 'var(--r-2)',
+ border: '1px solid var(--line-2)',
+ background: 'var(--ink-3)',
+ color: 'var(--fg-1)',
+ outline: 'none',
+ width: '100%',
+ boxSizing: 'border-box',
+ }}
+ onFocus={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'var(--shadow-brass)';
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'none';
+ }}
+ />
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
+ <label
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ color: 'var(--fg-3)',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ Sessions
+ </label>
+ <input
+ type="number"
+ min={1}
+ max={20}
+ value={newSessions}
+ onChange={(e) => setNewSessions(Math.max(1, parseInt(e.target.value) || 1))}
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '14px',
+ padding: '4px 8px',
+ borderRadius: 'var(--r-2)',
+ border: '1px solid var(--line-2)',
+ background: 'var(--ink-3)',
+ color: 'var(--fg-1)',
+ outline: 'none',
+ width: '56px',
+ textAlign: 'center',
+ }}
+ />
+ <button
+ onClick={handleAdd}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ fontWeight: 600,
+ padding: '4px 12px',
+ borderRadius: 'var(--r-1)',
+ border: 'none',
+ background: 'var(--brass)',
+ color: 'var(--brass-ink)',
+ cursor: 'pointer',
+ marginLeft: 'auto',
+ 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)';
+ }}
+ >
+ Save
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Task list */}
+ <div style={{ overflowY: 'auto', flex: 1 }}>
+ {tasks.length === 0 && (
+ <div
+ style={{
+ padding: '24px 16px',
+ textAlign: 'center',
+ fontFamily: 'var(--font-sans)',
+ fontSize: '13px',
+ color: 'var(--fg-4)',
+ }}
+ >
+ No tasks yet
+ </div>
+ )}
+ {tasks.map((task) => {
+ const isSelected = task.id === currentTaskId;
+ const isCompleted = task.completed;
+
+ return (
+ <div
+ key={task.id}
+ onClick={() => !isCompleted && handleSelect(task)}
+ style={{
+ padding: '10px 16px',
+ borderBottom: '1px solid var(--line-1)',
+ borderLeft: isSelected ? '2px solid var(--brass)' : '2px solid transparent',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ cursor: isCompleted ? 'default' : 'pointer',
+ background: isSelected ? 'var(--ink-2)' : 'transparent',
+ opacity: isCompleted ? 0.45 : 1,
+ transition: 'background 0.15s ease, border-color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ if (!isSelected && !isCompleted) {
+ (e.currentTarget as HTMLDivElement).style.background = 'var(--ink-2)';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected) {
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
+ }
+ }}
+ >
+ {/* Task name */}
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ color: isCompleted ? 'var(--fg-4)' : 'var(--fg-2)',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {task.name}
+ </div>
+ {/* Session count */}
+ <div
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '11px',
+ color: 'var(--fg-4)',
+ fontVariantNumeric: 'tabular-nums',
+ marginTop: '2px',
+ }}
+ >
+ {task.remaining_sessions} / {task.total_sessions}
+ </div>
+ </div>
+
+ {/* Delete */}
+ <button
+ onClick={(e) => handleDelete(e, task.id)}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '16px',
+ lineHeight: 1,
+ padding: '2px 4px',
+ border: 'none',
+ background: 'transparent',
+ color: 'var(--fg-4)',
+ cursor: 'pointer',
+ flexShrink: 0,
+ transition: 'color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.color = 'var(--negative)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-4)';
+ }}
+ title="Remove task"
+ >
+ x
+ </button>
+ </div>
+ );
+ })}
+ </div>
+ </aside>
+ );
+}
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>
+ );
+}
diff --git a/src/hooks/useTimerEvents.ts b/src/hooks/useTimerEvents.ts
new file mode 100644
index 0000000..2bc502d
--- /dev/null
+++ b/src/hooks/useTimerEvents.ts
@@ -0,0 +1,78 @@
+import { useEffect } from 'react';
+import { listen } from '@tauri-apps/api/event';
+import { invoke } from '@tauri-apps/api/core';
+import { useTimerStore, TimerTickPayload } from '../store/timerStore';
+import { useTaskStore } from '../store/taskStore';
+
+interface PhaseChangedPayload {
+ phase: TimerTickPayload['phase'];
+ session_count: number;
+}
+
+interface CompletedPayload {
+ task_id: string | null;
+}
+
+export function useTimerEvents(
+ onCompleted: (taskId: string | null) => void,
+) {
+ const setTimerTick = useTimerStore((s) => s.setTimerTick);
+ const setRunning = useTimerStore((s) => s.setRunning);
+ const fetchTasks = useTaskStore((s) => s.fetchTasks);
+
+ useEffect(() => {
+ // Bootstrap initial state from backend
+ invoke<{
+ phase: TimerTickPayload['phase'];
+ remaining_secs: number;
+ total_secs: number;
+ running: boolean;
+ session_count: number;
+ current_task_id: string | null;
+ }>('get_timer_status')
+ .then((status) => {
+ setTimerTick({
+ phase: status.phase,
+ remaining_secs: status.remaining_secs,
+ total_secs: status.total_secs,
+ session_count: status.session_count,
+ current_task_id: status.current_task_id,
+ });
+ setRunning(status.running);
+ })
+ .catch((e) => console.error('get_timer_status error:', e));
+
+ let unlistenTick: (() => void) | null = null;
+ let unlistenCompleted: (() => void) | null = null;
+ let unlistenPhaseChanged: (() => void) | null = null;
+
+ listen<TimerTickPayload>('timer-tick', (event) => {
+ setTimerTick(event.payload);
+ setRunning(true);
+ }).then((fn) => { unlistenTick = fn; });
+
+ listen<CompletedPayload>('timer-completed', (event) => {
+ setRunning(false);
+ onCompleted(event.payload.task_id);
+ // Refresh tasks since remaining_sessions may have changed
+ fetchTasks();
+ }).then((fn) => { unlistenCompleted = fn; });
+
+ listen<PhaseChangedPayload>('timer-phase-changed', (event) => {
+ setRunning(false);
+ setTimerTick({
+ phase: event.payload.phase,
+ remaining_secs: useTimerStore.getState().remainingSecs,
+ total_secs: useTimerStore.getState().totalSecs,
+ session_count: event.payload.session_count,
+ current_task_id: useTimerStore.getState().currentTaskId,
+ });
+ }).then((fn) => { unlistenPhaseChanged = fn; });
+
+ return () => {
+ unlistenTick?.();
+ unlistenCompleted?.();
+ unlistenPhaseChanged?.();
+ };
+ }, [setTimerTick, setRunning, fetchTasks, onCompleted]);
+}
diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts
new file mode 100644
index 0000000..ea7be34
--- /dev/null
+++ b/src/store/settingsStore.ts
@@ -0,0 +1,33 @@
+import { create } from 'zustand';
+import { invoke } from '@tauri-apps/api/core';
+
+export interface Settings {
+ work_duration_secs: number;
+ short_break_secs: number;
+ long_break_secs: number;
+ sessions_before_long_break: number;
+}
+
+interface SettingsStore {
+ settings: Settings | null;
+ fetchSettings: () => Promise<void>;
+ updateSettings: (s: Settings) => Promise<void>;
+}
+
+export const useSettingsStore = create<SettingsStore>((set) => ({
+ settings: null,
+
+ fetchSettings: async () => {
+ try {
+ const settings = await invoke<Settings>('get_settings');
+ set({ settings });
+ } catch (e) {
+ console.error('fetchSettings error:', e);
+ }
+ },
+
+ updateSettings: async (s) => {
+ await invoke('update_settings', { settings: s });
+ set({ settings: s });
+ },
+}));
diff --git a/src/store/taskStore.ts b/src/store/taskStore.ts
new file mode 100644
index 0000000..dccd17d
--- /dev/null
+++ b/src/store/taskStore.ts
@@ -0,0 +1,80 @@
+import { create } from 'zustand';
+import { invoke } from '@tauri-apps/api/core';
+
+export interface Task {
+ id: string;
+ name: string;
+ total_sessions: number;
+ remaining_sessions: number;
+ completed: boolean;
+ created_at: string;
+}
+
+interface TaskStore {
+ tasks: Task[];
+ loading: boolean;
+ fetchTasks: () => Promise<void>;
+ addTask: (name: string, totalSessions: number) => Promise<void>;
+ updateTask: (id: string, remainingSessions?: number, completed?: boolean) => Promise<void>;
+ deleteTask: (id: string) => Promise<void>;
+ setCurrentTask: (id: string | null) => Promise<void>;
+ refreshTask: (task: Task) => void;
+}
+
+export const useTaskStore = create<TaskStore>((set) => ({
+ tasks: [],
+ loading: false,
+
+ fetchTasks: async () => {
+ set({ loading: true });
+ try {
+ const tasks = await invoke<Task[]>('get_tasks');
+ set({ tasks, loading: false });
+ } catch (e) {
+ console.error('fetchTasks error:', e);
+ set({ loading: false });
+ }
+ },
+
+ addTask: async (name, totalSessions) => {
+ const task = await invoke<Task>('add_task', {
+ name,
+ totalSessions,
+ });
+ set((state) => ({ tasks: [...state.tasks, task] }));
+ },
+
+ updateTask: async (id, remainingSessions, completed) => {
+ await invoke('update_task', {
+ id,
+ remainingSessions: remainingSessions ?? null,
+ completed: completed ?? null,
+ });
+ set((state) => ({
+ tasks: state.tasks.map((t) =>
+ t.id === id
+ ? {
+ ...t,
+ remaining_sessions: remainingSessions ?? t.remaining_sessions,
+ completed: completed ?? t.completed,
+ }
+ : t,
+ ),
+ }));
+ },
+
+ deleteTask: async (id) => {
+ await invoke('delete_task', { id });
+ set((state) => ({ tasks: state.tasks.filter((t) => t.id !== id) }));
+ },
+
+ setCurrentTask: async (id) => {
+ await invoke('set_current_task', { taskId: id });
+ },
+
+ refreshTask: (updatedTask) => {
+ set((state) => ({
+ tasks: state.tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)),
+ }));
+ },
+}));
diff --git a/src/store/timerStore.ts b/src/store/timerStore.ts
new file mode 100644
index 0000000..6a81e20
--- /dev/null
+++ b/src/store/timerStore.ts
@@ -0,0 +1,42 @@
+import { create } from 'zustand';
+
+export type TimerPhase = 'work' | 'short_break' | 'long_break';
+
+export interface TimerTickPayload {
+ phase: TimerPhase;
+ remaining_secs: number;
+ total_secs: number;
+ session_count: number;
+ current_task_id: string | null;
+}
+
+interface TimerState {
+ phase: TimerPhase;
+ remainingSecs: number;
+ totalSecs: number;
+ running: boolean;
+ sessionCount: number;
+ currentTaskId: string | null;
+ setTimerTick: (payload: TimerTickPayload) => void;
+ setRunning: (running: boolean) => void;
+}
+
+export const useTimerStore = create<TimerState>((set) => ({
+ phase: 'work',
+ remainingSecs: 25 * 60,
+ totalSecs: 25 * 60,
+ running: false,
+ sessionCount: 0,
+ currentTaskId: null,
+
+ setTimerTick: (payload) =>
+ set({
+ phase: payload.phase,
+ remainingSecs: payload.remaining_secs,
+ totalSecs: payload.total_secs,
+ sessionCount: payload.session_count,
+ currentTaskId: payload.current_task_id,
+ }),
+
+ setRunning: (running) => set({ running }),
+}));