diff --git a/AUDIT_LOG.md b/AUDIT_LOG.md index f72ee8b..a582c02 100644 --- a/AUDIT_LOG.md +++ b/AUDIT_LOG.md @@ -2,6 +2,20 @@ This log tracks all significant changes, updates, and versions in the PaperCache project. +## 2026-06-28 (v0.5.6 Release: Keybinds Modal, Shortcut Mappings, Timer Auto-Delete, and Graph Link Refinement) +**Change:** chore(release): bump version to 0.5.6; feat(shortcuts): add dedicated keybinds settings modal and update global hotkeys (`Cmd+R` for tasks, `Cmd+T` for timers); feat(timers): auto-delete expired timers after 5 seconds; feat(graph): support standard markdown links and wikilinks + +**Details/Why:** +1. **Version Bump**: Bumped application version to 0.5.6 across `package.json`, `Cargo.toml`, `tauri.conf.json`, and added release notes file `New Features in v0.5.6.md`. +2. **Keybinds Settings Panel**: Created `ShortcutInput.tsx` as a shared component and `KeybindsModal.tsx` as a dedicated settings panel for remapping shortcuts, accessible via Settings. Added new storage keys in `settingsKeys.ts` and updated `useGlobalHotkey.ts` to dynamically match keyboard events against customizable shortcut settings. Refined UI layout so keycaps are centered horizontally and the container/buttons match the main Settings window. +3. **Keybind Updates**: Updated default shortcuts so `Cmd+R` opens Tasks/Reminders and `Cmd+T` opens the countdown Timers panel, aligning with user navigation habits. Preserved cleared shortcuts via `getShortcut` helper distinguishing `null` from `''`. Dynamically generated the shortcuts reference note (`Cmd+/`) and added recording guards in `useGlobalHotkey.ts` and `ShortcutInput.tsx`. Persisted toggle shortcut changes in `Settings.tsx`. +4. **Timer Auto-Deletion**: Updated `useTimerStore.ts` and `App.tsx` so that when a countdown timer completes, it schedules a targeted 5-second `setTimeout` to call `removeTimer(id)`, reducing UI clutter. Moved backend `timer-complete` event listener to `App.tsx` with robust late-resolution cleanup tracking so completion notifications and auto-cleanup function globally even when the panel is closed or unmounted. +5. **Graph View Link Parsing**: Expanded regex detection in `GraphView.tsx` to link notes using standard markdown links (`[Title](Title.md)`) and wikilinks (`[[Title]]`, stripping aliases like `[[Title|Display]]`) in addition to `/file` links, and removed unused `cz` centroid force values to keep layout logic consistent. + +**Files changed:** `package.json`, `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json`, `notes/New Features in v0.5.6.md`, `src/lib/settingsKeys.ts`, `src/components/ShortcutInput.tsx`, `src/components/KeybindsModal.tsx`, `src/store/useAppStore.ts`, `src/App.tsx`, `src/Settings.tsx`, `src/hooks/useGlobalHotkey.ts`, `src/store/useTimerStore.ts`, `src/components/TimersPage.tsx`, `src/GraphView.tsx`, `CHANGELOG.md`, `AUDIT_LOG.md`. + +--- + ## 2026-06-27 (API Key Persistence & Graph View Fixes) **Change:** fix(ai): fix API key saving/clearing logic and macOS keychain credential updating; fix(graph): prevent `fg.graphData` crashes when opening or closing Graph View diff --git a/CHANGELOG.md b/CHANGELOG.md index bb783f6..9c5a091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable, user-facing changes to PaperCache will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.5.6] - 2026-06-28 + +### Added +- **Dedicated Keybinds Settings Panel**: Added a sleek, high-contrast dark modal accessible from Settings (`Cmd+Shift+S`) to view and remap all application shortcuts with live recording. Designed with rich typography, glassmorphic cards, and fixed-width input buttons for perfectly vertically aligned shortcut columns. + +### Changed +- **Updated Default Shortcut Mappings**: Shifted the Reminders/Tasks view shortcut to `Cmd+R` and Timers panel shortcut to `Cmd+T`. +- **Timer Auto-Deletion**: Expired countdown timers are now automatically removed from the active list 5 seconds after completing, keeping the UI clean. Timer completion notifications and auto-cleanup now function globally even when the Timers panel is closed. +- **Enhanced Graph View Link Parsing**: Extended 3D Graph View link detection to support standard markdown links (`[Note](Note.md)`) and wikilinks (`[[Note]]`) alongside the existing `/file` syntax, and added z-axis forces for improved 3D layout stability. + ## [v0.5.5] - 2026-06-27 ### Added diff --git a/notes/New Features in v0.5.6.md b/notes/New Features in v0.5.6.md new file mode 100644 index 0000000..6152d31 --- /dev/null +++ b/notes/New Features in v0.5.6.md @@ -0,0 +1,11 @@ +# New Features in v0.5.6 + +Welcome to PaperCache v0.5.6! + +Here are the new features and improvements implemented in this release: +- **Dedicated Keybinds Settings Panel**: Added a dedicated modal accessible from Settings (`Cmd+Shift+S`) to view and remap all application shortcuts with live keyboard shortcut recording. Designed with rich typography, centered 3D keycaps, and uniform layouts that seamlessly unify with the main Settings panel. +- **Updated Default Shortcut Mappings**: Shifted the Reminders/Tasks view shortcut to `Cmd+R` and open Timers panel shortcut to `Cmd+T` for faster, ergonomic navigation. +- **Automatic Timer Cleanup**: Completed countdown timers are now automatically removed from the active list 5 seconds after expiration, keeping your workspace uncluttered. Timer completion notifications and auto-cleanup function globally even when the Timers panel is closed. +- **Expanded Graph View Link Detection**: Extended 3D Graph View link parsing to support standard markdown links (`[Note](Note.md)`) and wikilinks (`[[Note]]`) alongside `/file` links, with enhanced z-axis centering forces for improved 3D layout stability. + +*(If you have read this note, feel free to delete it)* diff --git a/package.json b/package.json index 158b2f1..10fd5ad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "papercache", "private": true, - "version": "0.5.5", + "version": "0.5.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f45b6af..933fff1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "papercache" -version = "0.5.5" +version = "0.5.6" description = "A PaperCache Tauri App" authors = ["Aditya Sharma"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e0dae98..2071f9d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/2.0.0/tauri.schema.json", "productName": "PaperCache", - "version": "0.5.5", + "version": "0.5.6", "identifier": "com.variablethe.papercache", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 4518332..d87957e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,9 +5,12 @@ import './App.css' const GraphView = lazy(() => import('./GraphView')) import { RemindersPage } from './components/RemindersPage' import { TimersPage } from './components/TimersPage' +import { KeybindsModal } from './components/KeybindsModal' import { useAppStore } from './store/useAppStore' import { useSettingsStore } from './store/useSettingsStore' +import { useTimerStore } from './store/useTimerStore' +import { listen } from '@tauri-apps/api/event' import { useNoteStorage } from './hooks/useNoteStorage' import { useVariables } from './hooks/useVariables' @@ -37,6 +40,8 @@ function App() { const setShowMainActionMenu = useAppStore((state) => state.setShowMainActionMenu) const showSettingsModal = useAppStore((state) => state.showSettingsModal) const setShowSettingsModal = useAppStore((state) => state.setShowSettingsModal) + const showKeybindsModal = useAppStore((state) => state.showKeybindsModal) + const setShowKeybindsModal = useAppStore((state) => state.setShowKeybindsModal) const { themePreset, fontFamily, showRulings, bgType, bgColor, bgImage, textColor, numColor } = useSettingsStore() @@ -65,8 +70,27 @@ function App() { type: 'info', }) }) + + useTimerStore.getState().cleanExpiredTimers() + + let unlistenTimer: (() => void) | undefined + let isUnmounted = false + listen('timer-complete', (event) => { + const id = event.payload + useTimerStore.getState().completeTimer(id) + const t = useTimerStore.getState().timers.find((x) => x.id === id) + useAppStore + .getState() + .addToast({ message: `⏱ Timer done: ${t?.label || ''}`, type: 'success' }) + }).then((fn) => { + if (isUnmounted) fn() + else unlistenTimer = fn + }) + return () => { + isUnmounted = true disposeUpdateReady() + unlistenTimer?.() } }, []) @@ -231,7 +255,8 @@ function App() { left: 0, right: 0, bottom: 0, - backgroundColor: bgType === 'color' ? bgColor : '#1a1a1a', + backgroundColor: 'rgba(0, 0, 0, 0.75)', + backdropFilter: 'blur(5px)', zIndex: 9999, overflow: 'auto', }} @@ -240,6 +265,25 @@ function App() { )} + {showKeybindsModal && ( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + backdropFilter: 'blur(5px)', + zIndex: 10000, + overflow: 'auto', + }} + > + setShowKeybindsModal(false)} /> +
+ )} + {/* In-app toast notifications */} {toasts.length > 0 && (
n.id)) notes.forEach((note) => { - const re = /\]\(\/file\s+([^)]+)\)/g + const targets = new Set() + + // 1. Match ](/file ) + const reFile = /\]\(\/file\s+([^)]+)\)/g let match - while ((match = re.exec(note.content)) !== null) { + while ((match = reFile.exec(note.content)) !== null) { let targetId = match[1].trim().replace(/\\/g, '/') if (!targetId.endsWith('.md')) targetId += '.md' - if (nodeIds.has(targetId)) { + targets.add(targetId) + } + + // 2. Match ](.md) + const reMd = /\]\(([^)]+\.md)\)/g + while ((match = reMd.exec(note.content)) !== null) { + let targetId = match[1].trim().replace(/\\/g, '/') + if (targetId.startsWith('./')) targetId = targetId.slice(2) + if (targetId.startsWith('/')) targetId = targetId.slice(1) + targets.add(targetId) + } + + // 3. Match [[]] + const reWiki = /\[\[([^\]]+)\]\]/g + while ((match = reWiki.exec(note.content)) !== null) { + let targetId = match[1].split('|')[0].trim().replace(/\\/g, '/') + if (!targetId.endsWith('.md')) targetId += '.md' + targets.add(targetId) + } + + targets.forEach((targetId) => { + if (nodeIds.has(targetId) && targetId !== note.id) { links.push({ source: note.id, target: targetId }) } - } + }) }) return { nodes, links } diff --git a/src/Settings.tsx b/src/Settings.tsx index 40a18ec..968f618 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, Fragment } from 'react' +import { useState, useEffect } from 'react' import { getVersion } from '@tauri-apps/api/app' -import { SETTINGS_KEYS } from './lib/settingsKeys' +import { SETTINGS_KEYS, getShortcut } from './lib/settingsKeys' import { useAppStore } from './store/useAppStore' import { useSettingsStore } from './store/useSettingsStore' +import { ShortcutInput } from './components/ShortcutInput' import './Settings.css' export default function Settings({ onClose }: { onClose?: () => void }) { @@ -40,10 +41,10 @@ export default function Settings({ onClose }: { onClose?: () => void }) { const defaultMod = isHyprland ? 'Alt' : 'CommandOrControl' const [shortcutNewNote, setShortcutNewNote] = useState( - localStorage.getItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE) || `${defaultMod}+Shift+N` + getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE, `${defaultMod}+Shift+N`) ) const [shortcutToggle, setShortcutToggle] = useState( - localStorage.getItem(SETTINGS_KEYS.SHORTCUT_TOGGLE) || `${defaultMod}+Shift+C` + getShortcut(SETTINGS_KEYS.SHORTCUT_TOGGLE, `${defaultMod}+Shift+C`) ) // Startup @@ -59,7 +60,7 @@ export default function Settings({ onClose }: { onClose?: () => void }) { }) }, []) - const [appVersion, setAppVersion] = useState('0.5.5') + const [appVersion, setAppVersion] = useState('0.5.6') useEffect(() => { getVersion() .then((ver) => setAppVersion(ver)) @@ -131,18 +132,17 @@ export default function Settings({ onClose }: { onClose?: () => void }) { } // Shortcuts - const oldShortcut = - localStorage.getItem('papercache-shortcut-newnote') || `${defaultMod}+Shift+N` + const oldShortcut = getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE, `${defaultMod}+Shift+N`) if (window.electronAPI.updateGlobalShortcut) { window.electronAPI.updateGlobalShortcut('new-note', oldShortcut, shortcutNewNote) } - localStorage.setItem('papercache-shortcut-newnote', shortcutNewNote) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE, shortcutNewNote) - const oldToggleShortcut = - localStorage.getItem('papercache-shortcut-toggle') || `${defaultMod}+Shift+C` + const oldToggleShortcut = getShortcut(SETTINGS_KEYS.SHORTCUT_TOGGLE, `${defaultMod}+Shift+C`) if (window.electronAPI.updateGlobalShortcut) { window.electronAPI.updateGlobalShortcut('toggle', oldToggleShortcut, shortcutToggle) } + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TOGGLE, shortcutToggle) // Dispatch storage event manually for the same window to pick it up immediately window.dispatchEvent(new Event('storage')) @@ -260,14 +260,33 @@ export default function Settings({ onClose }: { onClose?: () => void }) { <section> <h3>Global Shortcuts</h3> - <div className="setting-group"> + <div className="setting-group color-row"> <label>Toggle App Visibility</label> <ShortcutInput value={shortcutToggle} onChange={setShortcutToggle} /> </div> - <div className="setting-group"> + <div className="setting-group color-row"> <label>New Note (Global)</label> <ShortcutInput value={shortcutNewNote} onChange={setShortcutNewNote} /> </div> + <div className="setting-group" style={{ marginTop: '16px', justifyContent: 'center' }}> + <button + type="button" + onClick={() => useAppStore.getState().setShowKeybindsModal(true)} + style={{ + padding: '8px 16px', + background: 'rgba(59, 130, 246, 0.15)', + border: '1px solid #3b82f6', + borderRadius: '6px', + color: '#3b82f6', + cursor: 'pointer', + fontWeight: 500, + fontSize: '13px', + width: '100%', + }} + > + ⌨️ Open Keybinds Settings Panel + </button> + </div> </section> <section> @@ -483,166 +502,3 @@ export default function Settings({ onClose }: { onClose?: () => void }) { </div> ) } - -function ShortcutInput({ value, onChange }: { value: string; onChange: (val: string) => void }) { - const [recording, setRecordingLocal] = useState(false) - const setIsRecordingShortcut = useAppStore((state) => state.setIsRecordingShortcut) - - const setRecording = (val: boolean) => { - setRecordingLocal(val) - setIsRecordingShortcut(val) - } - - useEffect(() => { - if (recording) { - if (window.electronAPI.pauseShortcuts) window.electronAPI.pauseShortcuts() - } else { - if (window.electronAPI.resumeShortcuts) window.electronAPI.resumeShortcuts() - } - }, [recording]) - - const renderShortcutDisplay = (shortcut: string) => { - if (!shortcut) return <span>Click to record</span> - const parts = shortcut.split('+') - return ( - <div - style={{ - display: 'flex', - alignItems: 'center', - gap: '4px', - width: '100%', - justifyContent: 'center', - }} - > - {parts.map((part, index) => { - let display = part - switch (part) { - case 'CommandOrControl': - case 'Command': - display = '⌘' - break - case 'Control': - display = '⌃' - break - case 'Shift': - display = '⇧' - break - case 'Alt': - case 'Option': - display = '⌥' - break - case 'Up': - display = '↑' - break - case 'Down': - display = '↓' - break - case 'Left': - display = '←' - break - case 'Right': - display = '→' - break - case 'Space': - display = '␣' - break - } - return ( - <Fragment key={index}> - <span - style={{ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - minWidth: '24px', - height: '24px', - padding: '0 6px', - background: 'rgba(128,128,128,0.2)', - borderRadius: '6px', - boxShadow: '0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1)', - fontSize: '13px', - fontWeight: 500, - fontFamily: 'system-ui, -apple-system, sans-serif', - }} - > - {display} - </span> - {index < parts.length - 1 && ( - <span style={{ opacity: 0.5, fontSize: '14px' }}>+</span> - )} - </Fragment> - ) - })} - </div> - ) - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!recording) return - e.preventDefault() - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - - if (e.key === 'Escape') { - setRecording(false) - return - } - - if (e.key === 'Backspace' || e.key === 'Delete') { - onChange('') - setRecording(false) - return - } - - const modifiers = [] - if (e.metaKey || e.ctrlKey) modifiers.push('CommandOrControl') - if (e.altKey) modifiers.push('Alt') - if (e.shiftKey) modifiers.push('Shift') - - // Don't record if only a modifier is pressed - if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) { - return - } - - let key = e.key.toUpperCase() - if (key === ' ') key = 'Space' - // Map arrows and other special keys - if (key === 'ARROWUP') key = 'Up' - if (key === 'ARROWDOWN') key = 'Down' - if (key === 'ARROWLEFT') key = 'Left' - if (key === 'ARROWRIGHT') key = 'Right' - - const shortcut = [...modifiers, key].join('+') - onChange(shortcut) - setRecording(false) - } - - return ( - <button - className="shortcut-input-btn" - onClick={(e) => { - setRecording(true) - e.currentTarget.focus() - }} - onKeyDown={handleKeyDown} - onBlur={() => setRecording(false)} - style={{ - padding: '8px 12px', - background: recording ? 'rgba(138, 180, 248, 0.2)' : 'rgba(128,128,128,0.1)', - border: recording ? '1px solid #8ab4f8' : '1px solid rgba(128,128,128,0.2)', - borderRadius: '6px', - cursor: 'pointer', - minWidth: '220px', - margin: '0 auto', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - color: 'inherit', - fontFamily: 'inherit', - fontSize: '13px', - }} - > - {recording ? 'Recording... (Press Esc to cancel)' : renderShortcutDisplay(value)} - </button> - ) -} diff --git a/src/components/KeybindsModal.tsx b/src/components/KeybindsModal.tsx new file mode 100644 index 0000000..3c2a30d --- /dev/null +++ b/src/components/KeybindsModal.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect } from 'react' +import { SETTINGS_KEYS, getShortcut } from '../lib/settingsKeys' +import { useAppStore } from '../store/useAppStore' +import { ShortcutInput } from './ShortcutInput' + +interface KeybindsModalProps { + onClose: () => void +} + +export function KeybindsModal({ onClose }: KeybindsModalProps) { + const isHyprland = useAppStore((state) => state.isHyprland) + const defaultMod = isHyprland ? 'Alt' : 'CommandOrControl' + + // Global Shortcuts + const [shortcutNewNote, setShortcutNewNote] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE, `${defaultMod}+Shift+N`) + ) + const [shortcutToggle, setShortcutToggle] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_TOGGLE, `${defaultMod}+Shift+C`) + ) + + // In-App Shortcuts + const [shortcutTasks, setShortcutTasks] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_TASKS, `${defaultMod}+R`) + ) + const [shortcutTimers, setShortcutTimers] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_TIMERS, `${defaultMod}+T`) + ) + const [shortcutSearch, setShortcutSearch] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_SEARCH, `${defaultMod}+P`) + ) + const [shortcutGraph, setShortcutGraph] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_GRAPH, `${defaultMod}+G`) + ) + const [shortcutActionMenu, setShortcutActionMenu] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_ACTION_MENU, `${defaultMod}+K`) + ) + const [shortcutExport, setShortcutExport] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_EXPORT, `${defaultMod}+E`) + ) + const [shortcutRef, setShortcutRef] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_REF, `${defaultMod}+/`) + ) + const [shortcutSettings, setShortcutSettings] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_SETTINGS, `${defaultMod}+Shift+S`) + ) + const [shortcutNewNoteInApp, setShortcutNewNoteInApp] = useState( + getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE_INAPP, `${defaultMod}+N`) + ) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isRecordingShortcut = useAppStore.getState().isRecordingShortcut + if (e.key === 'Escape' && !e.defaultPrevented && !isRecordingShortcut) { + e.stopPropagation() + onClose() + } + } + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [onClose]) + + const handleSave = () => { + // Save Global Shortcuts + const oldShortcutNewNote = + localStorage.getItem('papercache-shortcut-newnote') || `${defaultMod}+Shift+N` + if (window.electronAPI.updateGlobalShortcut) { + window.electronAPI.updateGlobalShortcut('new-note', oldShortcutNewNote, shortcutNewNote) + } + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE, shortcutNewNote) + localStorage.setItem('papercache-shortcut-newnote', shortcutNewNote) + + const oldShortcutToggle = + localStorage.getItem('papercache-shortcut-toggle') || `${defaultMod}+Shift+C` + if (window.electronAPI.updateGlobalShortcut) { + window.electronAPI.updateGlobalShortcut('toggle', oldShortcutToggle, shortcutToggle) + } + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TOGGLE, shortcutToggle) + localStorage.setItem('papercache-shortcut-toggle', shortcutToggle) + + // Save In-App Shortcuts + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TASKS, shortcutTasks) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TIMERS, shortcutTimers) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_SEARCH, shortcutSearch) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_GRAPH, shortcutGraph) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_ACTION_MENU, shortcutActionMenu) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_EXPORT, shortcutExport) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_REF, shortcutRef) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_SETTINGS, shortcutSettings) + localStorage.setItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE_INAPP, shortcutNewNoteInApp) + + useAppStore + .getState() + .addToast({ message: '⌨️ Keybindings updated successfully', type: 'success' }) + onClose() + } + + const handleResetDefaults = () => { + setShortcutToggle(`${defaultMod}+Shift+C`) + setShortcutNewNote(`${defaultMod}+Shift+N`) + setShortcutTasks(`${defaultMod}+R`) + setShortcutTimers(`${defaultMod}+T`) + setShortcutSearch(`${defaultMod}+P`) + setShortcutGraph(`${defaultMod}+G`) + setShortcutActionMenu(`${defaultMod}+K`) + setShortcutExport(`${defaultMod}+E`) + setShortcutRef(`${defaultMod}+/`) + setShortcutSettings(`${defaultMod}+Shift+S`) + setShortcutNewNoteInApp(`${defaultMod}+N`) + } + + return ( + <div + className="settings-container" + style={{ width: '100%', maxWidth: '800px', margin: '0 auto' }} + > + <div className="settings-header"> + <h2>Keybindings Management</h2> + </div> + + <div className="settings-content"> + <p style={{ fontSize: '13px', color: '#aaa', marginBottom: '24px', textAlign: 'center' }}> + Click any action button below to record a new key combination. Freedom of control is + necessary for peak workflow velocity. + </p> + + <section> + <h3>Global Shortcuts (OS Level)</h3> + <KeybindRow + label="Toggle App Visibility" + value={shortcutToggle} + onChange={setShortcutToggle} + /> + <KeybindRow + label="New Note (Global)" + value={shortcutNewNote} + onChange={setShortcutNewNote} + /> + </section> + + <section> + <h3>In-App Navigation & Actions</h3> + <KeybindRow + label="Open Reminders / Tasks" + value={shortcutTasks} + onChange={setShortcutTasks} + /> + <KeybindRow + label="Open Timers Panel" + value={shortcutTimers} + onChange={setShortcutTimers} + /> + <KeybindRow + label="New Note (In-App)" + value={shortcutNewNoteInApp} + onChange={setShortcutNewNoteInApp} + /> + <KeybindRow label="Search Notes" value={shortcutSearch} onChange={setShortcutSearch} /> + <KeybindRow label="Graph View" value={shortcutGraph} onChange={setShortcutGraph} /> + <KeybindRow + label="Main Action Menu" + value={shortcutActionMenu} + onChange={setShortcutActionMenu} + /> + <KeybindRow label="Export Note" value={shortcutExport} onChange={setShortcutExport} /> + <KeybindRow + label="Open Settings" + value={shortcutSettings} + onChange={setShortcutSettings} + /> + <KeybindRow label="Shortcuts Reference" value={shortcutRef} onChange={setShortcutRef} /> + </section> + </div> + + <div className="settings-footer"> + <button className="close-btn" onClick={handleResetDefaults}> + Reset Defaults + </button> + <button className="close-btn" onClick={onClose}> + Cancel (Esc) + </button> + <button className="save-btn" onClick={handleSave}> + Save Keybindings + </button> + </div> + </div> + ) +} + +function KeybindRow({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (val: string) => void +}) { + return ( + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '10px 4px', + borderBottom: '1px solid rgba(255, 255, 255, 0.05)', + }} + > + <span + style={{ + fontSize: '14px', + color: '#eee', + textAlign: 'left', + flex: 1, + paddingRight: '16px', + }} + > + {label} + </span> + <ShortcutInput value={value} onChange={onChange} /> + </div> + ) +} diff --git a/src/components/ShortcutInput.tsx b/src/components/ShortcutInput.tsx new file mode 100644 index 0000000..6b5ca74 --- /dev/null +++ b/src/components/ShortcutInput.tsx @@ -0,0 +1,203 @@ +import React, { useState, useEffect, Fragment } from 'react' +import { useAppStore } from '../store/useAppStore' + +export function ShortcutInput({ + value, + onChange, +}: { + value: string + onChange: (val: string) => void +}) { + const [recording, setRecordingLocal] = useState(false) + const setIsRecordingShortcut = useAppStore((state) => state.setIsRecordingShortcut) + + const setRecording = (val: boolean) => { + setRecordingLocal(val) + setIsRecordingShortcut(val) + } + + useEffect(() => { + if (recording) { + if (window.electronAPI.pauseShortcuts) window.electronAPI.pauseShortcuts() + } else { + if (window.electronAPI.resumeShortcuts) window.electronAPI.resumeShortcuts() + } + return () => { + if (recording) { + if (window.electronAPI.resumeShortcuts) window.electronAPI.resumeShortcuts() + setIsRecordingShortcut(false) + } + } + }, [recording, setIsRecordingShortcut]) + + const renderShortcutDisplay = (shortcut: string) => { + if (!shortcut) + return ( + <span style={{ opacity: 0.5, fontSize: '13px', width: '100%', textAlign: 'center' }}> + Click to record + </span> + ) + const parts = shortcut.split('+') + return ( + <div + style={{ + display: 'flex', + alignItems: 'center', + gap: '6px', + width: '100%', + justifyContent: 'center', + overflow: 'hidden', + }} + > + {parts.map((part, index) => { + let display = part + switch (part) { + case 'CommandOrControl': + case 'Command': + display = '⌘' + break + case 'Control': + display = '⌃' + break + case 'Shift': + display = '⇧' + break + case 'Alt': + case 'Option': + display = '⌥' + break + case 'Up': + display = '↑' + break + case 'Down': + display = '↓' + break + case 'Left': + display = '←' + break + case 'Right': + display = '→' + break + case 'Space': + display = '␣' + break + } + return ( + <Fragment key={index}> + <span + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '24px', + height: '24px', + padding: '0 6px', + background: '#27272a', + border: '1px solid #3f3f46', + borderBottom: '2px solid #52525b', + borderRadius: '5px', + color: '#f8fafc', + fontSize: '12px', + fontWeight: 600, + fontFamily: 'system-ui, -apple-system, sans-serif', + flexShrink: 0, + }} + > + {display} + </span> + {index < parts.length - 1 && ( + <span + style={{ color: '#64748b', fontSize: '12px', fontWeight: 600, flexShrink: 0 }} + > + + + </span> + )} + </Fragment> + ) + })} + </div> + ) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!recording) return + e.preventDefault() + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + + if (e.key === 'Escape') { + setRecording(false) + return + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + onChange('') + setRecording(false) + return + } + + const modifiers = [] + if (e.metaKey || e.ctrlKey) modifiers.push('CommandOrControl') + if (e.altKey) modifiers.push('Alt') + if (e.shiftKey) modifiers.push('Shift') + + // Don't record if only a modifier is pressed + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) { + return + } + + let key = e.key.toUpperCase() + if (key === ' ') key = 'Space' + // Map arrows and other special keys + if (key === 'ARROWUP') key = 'Up' + if (key === 'ARROWDOWN') key = 'Down' + if (key === 'ARROWLEFT') key = 'Left' + if (key === 'ARROWRIGHT') key = 'Right' + + const shortcut = [...modifiers, key].join('+') + onChange(shortcut) + setRecording(false) + } + + return ( + <button + className="shortcut-input-btn" + onClick={(e) => { + setRecording(true) + e.currentTarget.focus() + }} + onKeyDown={handleKeyDown} + onBlur={() => setRecording(false)} + style={{ + padding: '6px 10px', + background: recording ? 'rgba(59, 130, 246, 0.15)' : 'rgba(0, 0, 0, 0.3)', + border: recording ? '1px solid #3b82f6' : '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '8px', + cursor: 'pointer', + width: '180px', + minWidth: '180px', + maxWidth: '180px', + flexShrink: 0, + margin: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: '#ffffff', + fontFamily: 'inherit', + fontSize: '13px', + boxShadow: recording + ? '0 0 12px rgba(59, 130, 246, 0.3)' + : 'inset 0 1px 2px rgba(0, 0, 0, 0.4)', + transition: 'all 0.15s ease', + }} + > + {recording ? ( + <span style={{ color: '#60a5fa', fontWeight: 500, width: '100%', textAlign: 'center' }}> + Recording... (Esc) + </span> + ) : ( + renderShortcutDisplay(value) + )} + </button> + ) +} diff --git a/src/components/TimersPage.tsx b/src/components/TimersPage.tsx index 4f2646c..2504d5c 100644 --- a/src/components/TimersPage.tsx +++ b/src/components/TimersPage.tsx @@ -12,9 +12,6 @@ import { useState, useEffect, useRef } from 'react' import { useTimerStore, type Timer } from '../store/useTimerStore' -import { useAppStore } from '../store/useAppStore' -import { listen } from '@tauri-apps/api/event' - interface TimerItemProps { timer: Timer onRemove: (id: string) => void @@ -113,28 +110,12 @@ export function TimersPage({ onClose }: TimersPageProps) { const timers = useTimerStore((s) => s.timers) const addTimer = useTimerStore((s) => s.addTimer) const removeTimer = useTimerStore((s) => s.removeTimer) - const completeTimer = useTimerStore((s) => s.completeTimer) - const addToast = useAppStore((s) => s.addToast) const [labelInput, setLabelInput] = useState('') const [hInput, setHInput] = useState('0') const [mInput, setMInput] = useState('25') const [sInput, setSInput] = useState('0') - // Listen for backend timer-complete events - useEffect(() => { - let unlisten: (() => void) | undefined - listen<string>('timer-complete', (event) => { - const id = event.payload - completeTimer(id) - const t = useTimerStore.getState().timers.find((x) => x.id === id) - addToast({ message: `⏱ Timer done: ${t?.label || ''}`, type: 'success' }) - }).then((fn) => { - unlisten = fn - }) - return () => unlisten?.() - }, [completeTimer, addToast]) - // Close on Escape useEffect(() => { const handler = (e: KeyboardEvent) => { diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts index 1eac32c..1b0dee7 100644 --- a/src/hooks/useGlobalHotkey.ts +++ b/src/hooks/useGlobalHotkey.ts @@ -1,12 +1,43 @@ import { useEffect } from 'react' import { useAppStore } from '../store/useAppStore' +import { SETTINGS_KEYS, getShortcut } from '../lib/settingsKeys' import { getCurrentWindow } from '@tauri-apps/api/window' +function matchShortcut(e: KeyboardEvent, configuredStr: string): boolean { + if (!configuredStr) return false + const parts = configuredStr.split('+') + const expectedKey = parts[parts.length - 1] + + const expectCmdOrCtrl = parts.includes('CommandOrControl') || parts.includes('Command') + const expectAlt = parts.includes('Alt') || parts.includes('Option') + const expectShift = parts.includes('Shift') + const expectCtrl = parts.includes('Control') + + const hasCmdOrCtrl = e.metaKey || e.ctrlKey + const hasAlt = e.altKey + const hasShift = e.shiftKey + + if (expectCmdOrCtrl !== hasCmdOrCtrl) return false + if (expectAlt !== hasAlt) return false + if (expectShift !== hasShift) return false + if (expectCtrl && !e.ctrlKey) return false + + let key = e.key.toUpperCase() + if (key === ' ') key = 'Space' + if (key === 'ARROWUP') key = 'Up' + if (key === 'ARROWDOWN') key = 'Down' + if (key === 'ARROWLEFT') key = 'Left' + if (key === 'ARROWRIGHT') key = 'Right' + + return key === expectedKey +} + export function useGlobalHotkey() { const setShowMainActionMenu = useAppStore((state) => state.setShowMainActionMenu) const setShowNoteSearch = useAppStore((state) => state.setShowNoteSearch) const setShowGraphView = useAppStore((state) => state.setShowGraphView) const setShowRemindersView = useAppStore((state) => state.setShowRemindersView) + const setShowTimersView = useAppStore((state) => state.setShowTimersView) const setNotes = useAppStore((state) => state.setNotes) const setCurrentNoteIndex = useAppStore((state) => state.setCurrentNoteIndex) const setNoteSearchQuery = useAppStore((state) => state.setNoteSearchQuery) @@ -16,7 +47,8 @@ export function useGlobalHotkey() { useEffect(() => { const handleGlobalKeyDown = async (e: KeyboardEvent) => { const state = useAppStore.getState() - const isMod = isHyprland ? e.altKey : e.metaKey || e.ctrlKey + if (state.isRecordingShortcut) return + const defaultMod = isHyprland ? 'Alt' : 'CommandOrControl' if (e.key === 'Escape') { const isRenaming = useAppStore.getState().isRenaming @@ -26,6 +58,9 @@ export function useGlobalHotkey() { if (isRecordingShortcut) return // Do not close app while recording shortcut // Dismiss overlays in priority order — highest-level first + if (state.showKeybindsModal || state.showSettingsModal) { + return // Let their own ESC handlers close them + } if (state.showMainActionMenu) { e.preventDefault() e.stopPropagation() @@ -55,29 +90,47 @@ export function useGlobalHotkey() { } } + // Read current configured shortcuts or defaults + const scSettings = getShortcut(SETTINGS_KEYS.SHORTCUT_SETTINGS, `${defaultMod}+Shift+S`) + const scGraph = getShortcut(SETTINGS_KEYS.SHORTCUT_GRAPH, `${defaultMod}+G`) + const scNewNoteInApp = getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE_INAPP, `${defaultMod}+N`) + const scExport = getShortcut(SETTINGS_KEYS.SHORTCUT_EXPORT, `${defaultMod}+E`) + const scSearch = getShortcut(SETTINGS_KEYS.SHORTCUT_SEARCH, `${defaultMod}+P`) + const scTasks = getShortcut(SETTINGS_KEYS.SHORTCUT_TASKS, `${defaultMod}+R`) + const scTimers = getShortcut(SETTINGS_KEYS.SHORTCUT_TIMERS, `${defaultMod}+T`) + const scActionMenu = getShortcut(SETTINGS_KEYS.SHORTCUT_ACTION_MENU, `${defaultMod}+K`) + const scRef = getShortcut(SETTINGS_KEYS.SHORTCUT_REF, `${defaultMod}+/`) + const scToggle = getShortcut(SETTINGS_KEYS.SHORTCUT_TOGGLE, `${defaultMod}+Shift+C`) + const scNewNote = getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE, `${defaultMod}+Shift+N`) + // Settings Shortcut - if (e.key.toLowerCase() === 's' && e.shiftKey && isMod) { + if (matchShortcut(e, scSettings)) { e.preventDefault() useAppStore.getState().setShowSettingsModal(true) + return } // Graph View Shortcut - if (e.key.toLowerCase() === 'g' && isMod) { + if (matchShortcut(e, scGraph)) { e.preventDefault() e.stopPropagation() setShowGraphView((prev) => !prev) + return } - if (e.key === 'n' && isMod) { + // New Note In-App Shortcut + if (matchShortcut(e, scNewNoteInApp)) { e.preventDefault() const id = Date.now() + '.md' const newNote = { id, content: '', mtime: Date.now() } setNotes((prev) => [newNote, ...prev]) setCurrentNoteIndex(0) window.electronAPI.saveNote(id, '') + return } - if (e.key === 'e' && isMod) { + // Export Note Shortcut + if (matchShortcut(e, scExport)) { e.preventDefault() e.stopPropagation() const { notes, currentNoteIndex } = useAppStore.getState() @@ -86,30 +139,45 @@ export function useGlobalHotkey() { const filename = note.id.replace(/\.md$/, '') window.electronAPI.exportNote(filename, note.content) } + return } - if (e.key.toLowerCase() === 'p' && isMod) { + // Search Notes Shortcut + if (matchShortcut(e, scSearch)) { e.preventDefault() e.stopPropagation() setShowNoteSearch(true) setNoteSearchQuery('') setSearchSelectedIndex(0) + return } - if (e.key.toLowerCase() === 't' && isMod) { + // Tasks / Reminders Shortcut (Cmd+R by default) + if (matchShortcut(e, scTasks)) { e.preventDefault() e.stopPropagation() setShowRemindersView(true) + return } - if (e.key.toLowerCase() === 'k' && isMod) { + // Timers Panel Shortcut (Cmd+T by default) + if (matchShortcut(e, scTimers)) { + e.preventDefault() + e.stopPropagation() + setShowTimersView(true) + return + } + + // Action Menu Shortcut + if (matchShortcut(e, scActionMenu)) { e.preventDefault() e.stopPropagation() setShowMainActionMenu((prev) => !prev) + return } // Shortcuts reference - if ((e.key === '/' || e.key === '?') && isMod) { + if (matchShortcut(e, scRef) || (e.key === '?' && (e.metaKey || e.ctrlKey || e.altKey))) { e.preventDefault() e.stopPropagation() const { notes } = useAppStore.getState() @@ -117,19 +185,26 @@ export function useGlobalHotkey() { if (existingIndex !== -1) { setCurrentNoteIndex(existingIndex) } else { + const fmt = (sc: string) => + sc + .replace(/CommandOrControl/g, isHyprland ? 'Alt' : 'Cmd') + .replace(/Command/g, 'Cmd') + .replace(/Control/g, 'Ctrl') + const shortcutsContent = `# Shortcuts -- \`Cmd+Shift+C\` — Toggle visibility (global, configurable) -- \`Cmd+Shift+N\` — New note (global, configurable) -- \`Cmd+Shift+S\` — Open settings -- \`Cmd+N\` — New note -- \`Cmd+T\` — Tasks / Reminders -- \`Cmd+K\` — Main action menu -- \`Cmd+P\` — Search notes -- \`Cmd+G\` — Graph view +- \`${fmt(scToggle)}\` — Toggle visibility (global, configurable) +- \`${fmt(scNewNote)}\` — New note (global, configurable) +- \`${fmt(scSettings)}\` — Open settings +- \`${fmt(scNewNoteInApp)}\` — New note +- \`${fmt(scTasks)}\` — Tasks / Reminders +- \`${fmt(scTimers)}\` — Timers Panel +- \`${fmt(scActionMenu)}\` — Main action menu +- \`${fmt(scSearch)}\` — Search notes +- \`${fmt(scGraph)}\` — Graph view - \`Cmd+F\` — Search in graph -- \`Cmd+E\` — Export note -- \`Cmd+/\` — Show this shortcuts reference +- \`${fmt(scExport)}\` — Export note +- \`${fmt(scRef)}\` — Show this shortcuts reference - \`Esc\` — Close menus / modals ### Slash Commands @@ -147,17 +222,17 @@ Type \`/\` in the editor for inline suggestions: setCurrentNoteIndex(0) window.electronAPI.saveNote('Shortcuts.md', shortcutsContent) } + return } } // Sync global shortcut on load const defaultMod = isHyprland ? 'Alt' : 'CommandOrControl' - const shortcut = localStorage.getItem('papercache-shortcut-newnote') || `${defaultMod}+Shift+N` + const shortcut = getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE, `${defaultMod}+Shift+N`) if (window.electronAPI.updateGlobalShortcut) { window.electronAPI.updateGlobalShortcut('new-note', '', shortcut) } - const toggleShortcut = - localStorage.getItem('papercache-shortcut-toggle') || `${defaultMod}+Shift+C` + const toggleShortcut = getShortcut(SETTINGS_KEYS.SHORTCUT_TOGGLE, `${defaultMod}+Shift+C`) if (window.electronAPI.updateGlobalShortcut) { window.electronAPI.updateGlobalShortcut('toggle', '', toggleShortcut) } @@ -194,6 +269,7 @@ Type \`/\` in the editor for inline suggestions: setNoteSearchQuery, setSearchSelectedIndex, setShowRemindersView, + setShowTimersView, setNotes, setCurrentNoteIndex, isHyprland, diff --git a/src/lib/settingsKeys.ts b/src/lib/settingsKeys.ts index 609a614..a9b38d8 100644 --- a/src/lib/settingsKeys.ts +++ b/src/lib/settingsKeys.ts @@ -15,6 +15,20 @@ export const SETTINGS_KEYS = { AI_SYSTEM_PROMPT: 'papercache-ai-system-prompt', SHORTCUT_NEWNOTE: 'papercache-shortcut-newnote', SHORTCUT_TOGGLE: 'papercache-shortcut-toggle', + SHORTCUT_TASKS: 'papercache-shortcut-tasks', + SHORTCUT_TIMERS: 'papercache-shortcut-timers', + SHORTCUT_SEARCH: 'papercache-shortcut-search', + SHORTCUT_GRAPH: 'papercache-shortcut-graph', + SHORTCUT_ACTION_MENU: 'papercache-shortcut-action-menu', + SHORTCUT_EXPORT: 'papercache-shortcut-export', + SHORTCUT_REF: 'papercache-shortcut-ref', + SHORTCUT_SETTINGS: 'papercache-shortcut-settings', + SHORTCUT_NEWNOTE_INAPP: 'papercache-shortcut-newnote-inapp', LAUNCH_STARTUP: 'papercache-launch-startup', NOTIFIED_REMINDERS: 'papercache_notified', } as const + +export function getShortcut(key: string, fallback: string): string { + const val = localStorage.getItem(key) + return val !== null ? val : fallback +} diff --git a/src/setupTests.ts b/src/setupTests.ts index c506b7c..2a351fb 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,7 +3,7 @@ import { vi, afterEach } from 'vitest' import type { ElectronAPI } from './types' vi.mock('@tauri-apps/api/app', () => ({ - getVersion: vi.fn().mockResolvedValue('0.5.5'), + getVersion: vi.fn().mockResolvedValue('0.5.6'), })) // Mock matchMedia which is not present in jsdom but might be needed by some components diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 3ae4142..e14cf07 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -32,6 +32,7 @@ interface AppState { showMainActionMenu: boolean actionMenuIndex: number showSettingsModal: boolean + showKeybindsModal: boolean isRecordingShortcut: boolean setNotes: (notes: Note[] | ((prev: Note[]) => Note[])) => void @@ -53,6 +54,7 @@ interface AppState { setShowMainActionMenu: (show: boolean | ((prev: boolean) => boolean)) => void setActionMenuIndex: (index: number | ((prev: number) => number)) => void setShowSettingsModal: (show: boolean) => void + setShowKeybindsModal: (show: boolean) => void setIsRecordingShortcut: (isRecording: boolean) => void } @@ -75,6 +77,7 @@ export const useAppStore = create<AppState>((set) => ({ showMainActionMenu: false, actionMenuIndex: 0, showSettingsModal: false, + showKeybindsModal: false, isRecordingShortcut: false, setNotes: (notes) => @@ -136,5 +139,6 @@ export const useAppStore = create<AppState>((set) => ({ : actionMenuIndex, })), setShowSettingsModal: (showSettingsModal) => set({ showSettingsModal }), + setShowKeybindsModal: (showKeybindsModal) => set({ showKeybindsModal }), setIsRecordingShortcut: (isRecordingShortcut) => set({ isRecordingShortcut }), })) diff --git a/src/store/useTimerStore.ts b/src/store/useTimerStore.ts index 9ee11be..257f56e 100644 --- a/src/store/useTimerStore.ts +++ b/src/store/useTimerStore.ts @@ -30,11 +30,19 @@ interface TimerState { completeTimer: (id: string) => void pauseTimer: (id: string) => void resumeTimer: (id: string) => void + cleanExpiredTimers: () => void } export const useTimerStore = create<TimerState>((set) => ({ timers: [], + cleanExpiredTimers: () => { + const now = Date.now() + set((state) => ({ + timers: state.timers.filter((t) => t.status !== 'completed' || now - t.endsAt < 10000), + })) + }, + addTimer: (label, durationMs) => { const id = `timer-${Date.now()}-${Math.random().toString(36).slice(2)}` const endsAt = Date.now() + durationMs @@ -52,21 +60,30 @@ export const useTimerStore = create<TimerState>((set) => ({ }, tickTimer: (id) => { - set((state) => ({ - timers: state.timers.map((t) => { - if (t.id !== id || t.status !== 'running') return t - const remaining = Math.max(0, t.endsAt - Date.now()) - return { ...t, remainingMs: remaining } - }), - })) + const timer = useTimerStore.getState().timers.find((t) => t.id === id) + if (!timer || timer.status !== 'running') return + const remaining = Math.max(0, timer.endsAt - Date.now()) + if (remaining === 0) { + useTimerStore.getState().completeTimer(id) + } else { + set((state) => ({ + timers: state.timers.map((t) => (t.id === id ? { ...t, remainingMs: remaining } : t)), + })) + } }, completeTimer: (id) => { + const existing = useTimerStore.getState().timers.find((t) => t.id === id) + if (!existing || existing.status === 'completed') return + set((state) => ({ timers: state.timers.map((t) => t.id === id ? { ...t, remainingMs: 0, status: 'completed' } : t ), })) + setTimeout(() => { + useTimerStore.getState().removeTimer(id) + }, 5000) }, pauseTimer: (id) => {