diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx new file mode 100644 index 0000000..220551c --- /dev/null +++ b/src/components/Settings.tsx @@ -0,0 +1,380 @@ +/** + * Settings -- User preferences and configuration panel. + * + * Features: + * - Theme toggle (dark/light) + * - Notification preferences + * - Sound effects toggle + * - Floating widget toggle + * - Hotkey configuration + * - Reconciliation interval + * - Default defer duration + * - Keyboard shortcuts display + * - Data directory info + */ + +import { useCallback, useState } from "react"; +import type { DeferDuration } from "@/lib/types"; + +/** Settings data structure matching ~/.local/share/mc/settings.json */ +export interface SettingsData { + schemaVersion: number; + hotkeys: { + toggle: string; + capture: string; + }; + lorePath: string | null; + reconciliationHours: number; + floatingWidget: boolean; + defaultDefer: DeferDuration; + sounds: boolean; + theme: "dark" | "light"; + notifications: boolean; +} + +export interface SettingsProps { + settings: SettingsData; + onSave: (settings: SettingsData) => void; + dataDir?: string; +} + +/** Keyboard shortcuts to display (not configurable, just informational) */ +const KEYBOARD_SHORTCUTS = [ + { action: "Start task", shortcut: "S" }, + { action: "Skip task", shortcut: "K" }, + { action: "Defer 1 hour", shortcut: "D" }, + { action: "Defer tomorrow", shortcut: "T" }, + { action: "Command palette", shortcut: "Cmd+K" }, + { action: "Quick capture", shortcut: "Cmd+Shift+C" }, +] as const; + +/** Validate hotkey format (e.g., Meta+Shift+M) */ +function isValidHotkey(value: string): boolean { + // Accept common modifier patterns + const pattern = /^(Meta|Ctrl|Alt|Shift)(\+(Meta|Ctrl|Alt|Shift))*\+[A-Z]$/; + return pattern.test(value); +} + +/** Toggle switch component */ +function Toggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +}): React.ReactElement { + const id = label.toLowerCase().replace(/\s+/g, "-"); + + return ( + + ); +} + +/** Section wrapper component */ +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}): React.ReactElement { + return ( +
+

{title}

+
{children}
+
+ ); +} + +export function Settings({ + settings, + onSave, + dataDir, +}: SettingsProps): React.ReactElement { + // Local state for form validation + const [hotkeyErrors, setHotkeyErrors] = useState<{ + toggle?: string; + capture?: string; + }>({}); + + // Local state for controlled hotkey inputs + const [hotkeyValues, setHotkeyValues] = useState({ + toggle: settings.hotkeys.toggle, + capture: settings.hotkeys.capture, + }); + + // Local state for reconciliation interval + const [reconciliationValue, setReconciliationValue] = useState( + String(settings.reconciliationHours) + ); + + // Handle toggle changes + const handleToggle = useCallback( + (field: keyof SettingsData, value: boolean) => { + onSave({ ...settings, [field]: value }); + }, + [settings, onSave] + ); + + // Handle theme toggle + const handleThemeToggle = useCallback( + (isDark: boolean) => { + onSave({ ...settings, theme: isDark ? "dark" : "light" }); + }, + [settings, onSave] + ); + + // Handle hotkey input change with live validation + const handleHotkeyInput = useCallback( + (field: "toggle" | "capture", value: string) => { + setHotkeyValues((prev) => ({ ...prev, [field]: value })); + + if (value && !isValidHotkey(value)) { + setHotkeyErrors((prev) => ({ + ...prev, + [field]: "Invalid hotkey format", + })); + } else { + setHotkeyErrors((prev) => { + const updated = { ...prev }; + delete updated[field]; + return updated; + }); + } + }, + [] + ); + + // Handle hotkey blur to save valid value + const handleHotkeyBlur = useCallback( + (field: "toggle" | "capture") => { + const value = hotkeyValues[field]; + if (value && isValidHotkey(value)) { + onSave({ + ...settings, + hotkeys: { ...settings.hotkeys, [field]: value }, + }); + } + }, + [settings, onSave, hotkeyValues] + ); + + // Handle reconciliation input change + const handleReconciliationChange = useCallback((value: string) => { + setReconciliationValue(value); + }, []); + + // Handle reconciliation blur (save valid value) + const handleReconciliationBlur = useCallback(() => { + const num = parseInt(reconciliationValue, 10); + if (!Number.isNaN(num) && num > 0) { + onSave({ ...settings, reconciliationHours: num }); + } + }, [settings, onSave, reconciliationValue]); + + // Handle select change + const handleSelectChange = useCallback( + (field: keyof SettingsData, value: string) => { + onSave({ ...settings, [field]: value }); + }, + [settings, onSave] + ); + + return ( +
+

Settings

+ + {/* Appearance */} +
+ +
+ + {/* Behavior */} +
+ handleToggle("notifications", v)} + /> + handleToggle("sounds", v)} + /> + handleToggle("floatingWidget", v)} + /> + + {/* Reconciliation interval */} +
+ + handleReconciliationChange(e.target.value)} + onBlur={handleReconciliationBlur} + className="w-20 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500" + /> +
+ + {/* Default defer duration */} +
+ + +
+
+ + {/* Hotkeys */} +
+
+
+ + handleHotkeyInput("toggle", e.target.value)} + onBlur={() => handleHotkeyBlur("toggle")} + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500" + /> + {hotkeyErrors.toggle && ( +

{hotkeyErrors.toggle}

+ )} +
+ +
+ + handleHotkeyInput("capture", e.target.value)} + onBlur={() => handleHotkeyBlur("capture")} + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500" + /> + {hotkeyErrors.capture && ( +

{hotkeyErrors.capture}

+ )} +
+
+
+ + {/* Keyboard Shortcuts (read-only) */} +
+
+ {KEYBOARD_SHORTCUTS.map(({ action, shortcut }) => ( +
+ {action} + + {shortcut} + +
+ ))} +
+
+ + {/* Data */} +
+ {/* Lore path */} +
+ + + onSave({ + ...settings, + lorePath: e.target.value || null, + }) + } + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-blue-500" + /> +

+ Leave empty to use the default location +

+
+ + {/* Data directory info */} + {dataDir && ( +
+

Data directory

+

{dataDir}

+
+ )} +
+
+ ); +} diff --git a/tests/components/Settings.test.tsx b/tests/components/Settings.test.tsx new file mode 100644 index 0000000..f039e06 --- /dev/null +++ b/tests/components/Settings.test.tsx @@ -0,0 +1,284 @@ +/** + * Tests for Settings component. + * + * TDD: These tests define the expected behavior before implementation. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Settings } from "@/components/Settings"; +import type { SettingsData } from "@/components/Settings"; + +const defaultSettings: SettingsData = { + schemaVersion: 1, + hotkeys: { + toggle: "Meta+Shift+M", + capture: "Meta+Shift+C", + }, + lorePath: null, + reconciliationHours: 6, + floatingWidget: false, + defaultDefer: "1h", + sounds: true, + theme: "dark", + notifications: true, +}; + +describe("Settings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("loading state", () => { + it("displays current settings on mount", () => { + render(); + + // Check hotkey displays + expect(screen.getByDisplayValue("Meta+Shift+M")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Meta+Shift+C")).toBeInTheDocument(); + }); + + it("shows data directory info", () => { + render( + + ); + + expect(screen.getByText(/\.local\/share\/mc/)).toBeInTheDocument(); + }); + }); + + describe("theme toggle", () => { + it("renders theme toggle with current value", () => { + render(); + + const themeToggle = screen.getByRole("switch", { name: /dark mode/i }); + expect(themeToggle).toBeChecked(); + }); + + it("calls onSave when theme is toggled", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const themeToggle = screen.getByRole("switch", { name: /dark mode/i }); + await user.click(themeToggle); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + theme: "light", + }) + ); + }); + }); + + describe("notification preferences", () => { + it("renders notification toggle", () => { + render(); + + const notifToggle = screen.getByRole("switch", { name: /notifications/i }); + expect(notifToggle).toBeChecked(); + }); + + it("calls onSave when notifications toggled", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const notifToggle = screen.getByRole("switch", { name: /notifications/i }); + await user.click(notifToggle); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + notifications: false, + }) + ); + }); + }); + + describe("sound effects", () => { + it("renders sound effects toggle", () => { + render(); + + const soundToggle = screen.getByRole("switch", { name: /sound effects/i }); + expect(soundToggle).toBeChecked(); + }); + + it("calls onSave when sound effects toggled", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const soundToggle = screen.getByRole("switch", { name: /sound effects/i }); + await user.click(soundToggle); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + sounds: false, + }) + ); + }); + }); + + describe("floating widget", () => { + it("renders floating widget toggle", () => { + render(); + + const widgetToggle = screen.getByRole("switch", { + name: /floating widget/i, + }); + expect(widgetToggle).not.toBeChecked(); + }); + + it("calls onSave when floating widget toggled", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const widgetToggle = screen.getByRole("switch", { + name: /floating widget/i, + }); + await user.click(widgetToggle); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + floatingWidget: true, + }) + ); + }); + }); + + describe("hotkey settings", () => { + it("validates hotkey format", async () => { + const user = userEvent.setup(); + render(); + + const toggleInput = screen.getByLabelText(/toggle hotkey/i); + await user.clear(toggleInput); + await user.type(toggleInput, "invalid"); + + expect(screen.getByText(/Invalid hotkey format/i)).toBeInTheDocument(); + }); + + it("accepts valid hotkey format", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const toggleInput = screen.getByLabelText(/toggle hotkey/i); + await user.clear(toggleInput); + await user.type(toggleInput, "Meta+Shift+K"); + await user.tab(); // Blur to trigger save + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + hotkeys: expect.objectContaining({ + toggle: "Meta+Shift+K", + }), + }) + ); + }); + }); + + describe("reconciliation interval", () => { + it("displays current interval", () => { + render(); + + expect(screen.getByDisplayValue("6")).toBeInTheDocument(); + }); + + it("updates interval on change", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const intervalInput = screen.getByLabelText(/reconciliation/i); + await user.clear(intervalInput); + await user.type(intervalInput, "12"); + await user.tab(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reconciliationHours: 12, + }) + ); + }); + }); + + describe("default defer duration", () => { + it("displays current default defer", () => { + render(); + + const deferSelect = screen.getByLabelText(/default defer/i); + expect(deferSelect).toHaveValue("1h"); + }); + + it("updates default defer on change", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + const deferSelect = screen.getByLabelText(/default defer/i); + await user.selectOptions(deferSelect, "3h"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + defaultDefer: "3h", + }) + ); + }); + }); + + describe("keyboard shortcuts display", () => { + it("shows keyboard shortcuts section", () => { + render(); + + expect(screen.getByText(/keyboard shortcuts/i)).toBeInTheDocument(); + }); + + it("displays common shortcuts", () => { + render(); + + // Check for common shortcut displays + expect(screen.getByText(/start task/i)).toBeInTheDocument(); + expect(screen.getByText(/skip task/i)).toBeInTheDocument(); + expect(screen.getByText(/command palette/i)).toBeInTheDocument(); + }); + }); + + describe("lore path", () => { + it("shows default lore path when null", () => { + render(); + + expect( + screen.getByPlaceholderText(/\.local\/share\/lore/i) + ).toBeInTheDocument(); + }); + + it("shows custom lore path when set", () => { + const settingsWithPath = { + ...defaultSettings, + lorePath: "/custom/path/lore.db", + }; + render(); + + expect(screen.getByDisplayValue("/custom/path/lore.db")).toBeInTheDocument(); + }); + }); + + describe("section organization", () => { + it("groups settings into sections", () => { + render(); + + // Check for section headers (h2 elements) + expect(screen.getByRole("heading", { name: /appearance/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /behavior/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /hotkeys/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^data$/i })).toBeInTheDocument(); + }); + }); +});