From e36a94c72760881c9ba1da447f5004310b1395ab Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 20 Jun 2026 00:21:29 +0530 Subject: [PATCH] feat: Add Tasks feature, onboard docs, and updates --- README.md | 2 + electron/main.ts | 33 +++- electron/preload.ts | 3 + features.md | 1 + src/App.css | 47 ++++++ src/App.tsx | 279 ++++++++++++++++++++++++++++++- src/components/RemindersPage.tsx | 242 +++++++++++++++++++++++++++ 7 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 src/components/RemindersPage.tsx diff --git a/README.md b/README.md index 09eb605..73ad1ef 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Summon it with a hotkey. Jot. Dismiss. It stays out of your way until you need i - **Inline AI** — type `/ai `, press enter, get the answer inserted directly into your note. No sidebar, no context switch. - **Auto-highlights hex colors, dates, and times** — `#D97757` renders as a color pill. `2024-05-31` gets highlighted. Useful at a glance. - **Interactive Checkboxes** — Type `/check` to create an interactive checkbox that strikes through text when clicked. +- **Tasks & Reminders** — Type `/task` followed by `@ 1d2h` to set a due date. Press `Cmd+T` to open a unified Tasks view that tracks all your pending items and due times. - **Tags & folders** — `!tagname` for tags, `/` in note titles for folders. Simple conventions, no UI overhead. - **Graph view** — see how your notes connect (`Cmd+G`). @@ -71,6 +72,7 @@ Built with Electron, React, TypeScript, and Vite. | `Cmd+Shift+N` | New note (global, configurable) | | `Cmd+Shift+S` | Open settings panel | | `Cmd+N` | New note (in-app) | +| `Cmd+T` | Open Tasks page | | `Cmd+K` | Main action menu | | `Cmd+P` | Search notes | | `Cmd+G` | Graph view | diff --git a/electron/main.ts b/electron/main.ts index e3d1ede..75a0726 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -158,6 +158,27 @@ This is a note about a !project. When you open the search menu (\`Cmd+P\`), you'll see all your unique tags at the top. Click any tag to instantly filter your notes! +Next: [Tasks](/file commands/tasks.md) + +[Back to Welcome](/file Welcome.md) +`, +) + +fs.writeFileSync( + path.join(COMMANDS_DIR, 'tasks.md'), + `# Tasks & Reminders + +Stay on top of your work by using tasks! + +Type \`/task\` to create a new task. +If you want to set a deadline, just type \` @ \` followed by a time shorthand after the task. +*Example use:* +/task Buy groceries @ 2h + +PaperCache understands shorthands like \`2d\`, \`3h45m\`, \`tmrw\`, or even exact dates like \`2024-12-31 15:00\`. +Once you set a task, press \`Cmd+T\` (or \`Ctrl+T\`) to open the Tasks Page and see everything that's due! +Overdue tasks will automatically highlight in red. + Next: [Ready](/file commands/ready.md) [Back to Welcome](/file Welcome.md) @@ -178,7 +199,7 @@ const welcomePath = path.join(NOTES_DIR, 'Welcome.md') let shouldWriteWelcome = true if (fs.existsSync(welcomePath)) { const content = fs.readFileSync(welcomePath, 'utf-8') - if (content.includes('[6. Tags]')) { + if (content.includes('[7. Tasks]')) { shouldWriteWelcome = false } } @@ -202,6 +223,7 @@ Try Cmd+Clicking these to learn the ropes: - [4. Markdown & Code](/file commands/markdown.md) - [5. Formats & Colors](/file commands/formats.md) - [6. Tags](/file commands/tags.md) +- [7. Tasks](/file commands/tasks.md) *(Press \`Cmd+K\` at any time to open the main menu!)* `, @@ -312,6 +334,15 @@ app.on('will-quit', () => { globalShortcut.unregisterAll() }) +app.on('web-contents-created', (event, contents) => { + contents.on('before-input-event', (e, input) => { + if ((input.control || input.meta) && input.key.toLowerCase() === 't') { + contents.send('trigger-tasks') + e.preventDefault() + } + }) +}) + app.whenReady().then(() => { createWindow() diff --git a/electron/preload.ts b/electron/preload.ts index 4a67fd5..2900375 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -22,4 +22,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onTriggerNewNote: (callback: () => void) => { ipcRenderer.on('trigger-new-note', () => callback()) }, + onTriggerTasks: (callback: () => void) => { + ipcRenderer.on('trigger-tasks', () => callback()) + }, }) diff --git a/features.md b/features.md index 08c889e..a316eb0 100644 --- a/features.md +++ b/features.md @@ -10,6 +10,7 @@ This document outlines every feature available in the PaperCache codebase, organ - **Color Format Recognition**: Automatically detects hex colors (e.g., `#D97757` or `#fff`) and renders a small inline preview color pill. - **Date & Time Formats**: Highlights standard date (`YYYY-MM-DD`) and time (`HH:MM` or `HH:MM:SS`) formats into clean, distinct pills. - **Interactive Checkboxes**: Type `/check` to create an interactive checkbox widget. Clicking it changes it to `/checked` and visually strikes through the text on that line! +- **Tasks & Reminders**: Type `/task` to create a task widget. Add a space followed by `@` and a time (like `1d2h`, `tmrw`, or a specific date `YYYY-MM-DD HH:MM`) to set a due date. Press `Cmd+T` (or `Ctrl+T`) to open the Tasks Page, which tracks all tasks, calculates due times, and highlights overdue tasks in red. - **Customizable Theming & Fonts**: Customize fonts, text colors, background colors, background images, and individual highlight colors for variables, AI, and math. Supports full dark mode (`grid-dark`, `blueprint`) and custom zoom scaling. ## Math, Variables, and Calculations diff --git a/src/App.css b/src/App.css index b0af487..4578fc3 100644 --- a/src/App.css +++ b/src/App.css @@ -657,3 +657,50 @@ body { text-decoration-color: #ff6b81; color: #ff6b81; } + +.cm-rem-widget { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: 2px solid #7EB8D4; /* pale steel blue */ + border-radius: 50%; + margin-right: 6px; + cursor: pointer; + vertical-align: middle; + color: transparent; + background-color: transparent; + transition: all 0.2s ease; + user-select: none; + margin-top: -2px; +} + +.cm-rem-widget.cm-rem-checked { + background-color: #7EB8D4; /* fill color */ + color: white; /* tick color */ +} + +.cm-rem-widget svg { + width: 12px; + height: 12px; +} + +.cm-rem-checked-line-text { + text-decoration: line-through; + text-decoration-color: #7EB8D4; + color: #7EB8D4; + opacity: 0.6; +} + +.cm-rem-line-text { + font-family: 'JetBrains Mono', 'Courier New', monospace; +} + +.cm-rem-widget.cm-rem-overdue { + border-color: #FF3B30; /* Red */ +} + +.cm-overdue-line-text { + color: #FF3B30 !important; +} diff --git a/src/App.tsx b/src/App.tsx index 578d353..8acdb35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { EditorView, keymap, WidgetType, + ViewUpdate, } from '@codemirror/view' import { Prec } from '@codemirror/state' import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language' @@ -17,6 +18,7 @@ import { insertTab, indentLess } from '@codemirror/commands' import * as mathjs from 'mathjs' import OpenAI from 'openai' import GraphView from './GraphView' +import { RemindersPage } from './components/RemindersPage' import './App.css' import { getFolderColor } from './utils' @@ -211,6 +213,56 @@ class VariableWidget extends WidgetType { } } +class ReminderWidget extends WidgetType { + checked: boolean + overdue: boolean + pos: number + view: EditorView + + constructor(checked: boolean, overdue: boolean, pos: number, view: EditorView) { + super() + this.checked = checked + this.overdue = overdue + this.pos = pos + this.view = view + } + + eq(other: ReminderWidget) { + return other.checked === this.checked && other.pos === this.pos && other.overdue === this.overdue + } + + toDOM() { + const wrap = document.createElement('span') + wrap.className = 'cm-rem-widget' + (this.checked ? ' cm-rem-checked' : '') + (this.overdue && !this.checked ? ' cm-rem-overdue' : '') + + if (this.checked) { + wrap.innerHTML = `` + } else { + wrap.innerHTML = `` // empty for unchecked, border provides the box + } + + // Use onmousedown to prevent CodeMirror from interfering with selection + wrap.onmousedown = (e) => { + e.preventDefault() + e.stopPropagation() + + const from = this.pos + const to = this.pos + (this.checked ? 10 : 5) // length of "/task-done" or "/task" + const insert = this.checked ? '/task' : '/task-done' + + this.view.dispatch({ + changes: { from, to, insert }, + }) + } + + return wrap + } + + ignoreEvent() { + return true + } +} + const hideMarkdownPlugin = ViewPlugin.fromClass( class { decorations @@ -522,6 +574,48 @@ const hideMarkdownPlugin = ViewPlugin.fromClass( } } } + + // Tasks (/task, /task-done) + const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+/g + while ((match = reRem.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const isChecked = match[1] === 'task-done' + + const line = view.state.doc.lineAt(start) + let isOverdue = false + const fullRe = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/ + const fullMatch = fullRe.exec(line.text) + if (fullMatch && fullMatch[4]) { + if (new Date(fullMatch[4]).getTime() < Date.now()) isOverdue = true + } + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new ReminderWidget(isChecked, isOverdue, start, view) }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-rem-highlight' }), + }) + } + + if (line.to > end) { + let classStr = 'cm-rem-line-text' + if (isChecked) classStr += ' cm-checked-line-text' + else if (isOverdue) classStr += ' cm-overdue-line-text' + + decos.push({ + from: end, + to: line.to, + deco: Decoration.mark({ class: classStr }), + }) + } + } } // end of visibleRanges iteration // Traverse AST for Code Blocks @@ -645,6 +739,80 @@ const hideMarkdownPlugin = ViewPlugin.fromClass( { decorations: (v) => v.decorations }, ) +const remConverterPlugin = ViewPlugin.fromClass( + class { + update(update: ViewUpdate) { + if (!update.docChanged) return + + const docStr = update.state.doc.toString() + const changes: {from: number, to: number, insert: string}[] = [] + + // Match /task or /task-done followed by a space, but ONLY if not already followed by a date bracket ( + const re = /^\/(task|task-done) (?!\()/gm + let match + while ((match = re.exec(docStr)) !== null) { + const now = new Date() + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const mins = String(now.getMinutes()).padStart(2, '0') + + const timestamp = `(${yyyy}-${mm}-${dd} ${hh}:${mins})` + + changes.push({ + from: match.index, + to: match.index + match[0].length, + insert: `/${match[1]} ${timestamp} ` + }) + } + + // Match shorthand timers at the end of a task, ONLY after a space or Enter is typed + const reShort = /^(\/(?:task|task-done)[^\n]*?@\s*)((?:[0-9]+[smhd])+|tmrw)([ \t]+|\n|(?:\r\n))/gm + while ((match = reShort.exec(docStr)) !== null) { + const now = new Date() + const short = match[2] + if (short === 'tmrw') { + now.setDate(now.getDate() + 1) + now.setHours(9, 0, 0, 0) + } else { + const partRe = /([0-9]+)([smhd])/g + let partMatch + while ((partMatch = partRe.exec(short)) !== null) { + const val = parseInt(partMatch[1]) + const unit = partMatch[2] + if (unit === 's') now.setSeconds(now.getSeconds() + val) + else if (unit === 'm') now.setMinutes(now.getMinutes() + val) + else if (unit === 'h') now.setHours(now.getHours() + val) + else if (unit === 'd') now.setDate(now.getDate() + val) + } + } + + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const mins = String(now.getMinutes()).padStart(2, '0') + + const absoluteDate = `${yyyy}-${mm}-${dd} ${hh}:${mins}` + + // Push the change to replace ONLY the shorthand part + changes.push({ + from: match.index + match[1].length, + to: match.index + match[1].length + match[2].length, + insert: absoluteDate + }) + } + + if (changes.length > 0) { + setTimeout(() => { + update.view.dispatch({ changes }) + }, 10) + } + } + } +) + interface Note { id: string content: string @@ -703,6 +871,7 @@ function App() { ) const [showGraphView, setShowGraphView] = useState(false) + const [showRemindersView, setShowRemindersView] = useState(false) const [isRenaming, setIsRenaming] = useState(false) const [renameValue, setRenameValue] = useState('') @@ -995,7 +1164,7 @@ function App() { } } - if (e.key === 'p' && (e.metaKey || e.ctrlKey)) { + if (e.key.toLowerCase() === 'p' && (e.metaKey || e.ctrlKey)) { e.preventDefault() e.stopPropagation() setShowNoteSearch(true) @@ -1003,7 +1172,13 @@ function App() { setSearchSelectedIndex(0) } - if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + if (e.key.toLowerCase() === 't' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setShowRemindersView(true) + } + + if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() e.stopPropagation() setShowMainActionMenu((prev) => !prev) @@ -1049,6 +1224,12 @@ function App() { }) } + if (window.electronAPI.onTriggerTasks) { + window.electronAPI.onTriggerTasks(() => { + setShowRemindersView((prev) => !prev) + }) + } + window.addEventListener('keydown', handleGlobalKeyDown, { capture: true }) return () => window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true }) }, [showMainActionMenu, showNoteSearch, showGraphView]) @@ -1235,6 +1416,7 @@ function App() { aiPlugin, mathPlugin, hideMarkdownPlugin, + remConverterPlugin, EditorView.domEventHandlers({ mousedown: (event, _view) => { const target = event.target as HTMLElement @@ -1276,6 +1458,48 @@ function App() { window.addEventListener('focus', handleWindowFocus) return () => window.removeEventListener('focus', handleWindowFocus) }, []) + useEffect(() => { + if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { + Notification.requestPermission() + } + + const interval = setInterval(() => { + const notifiedStr = localStorage.getItem('papercache_notified') || '[]' + const notified = new Set(JSON.parse(notifiedStr)) + let hasNewNotifs = false + + notes.forEach((note) => { + const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/gm + let match + while ((match = reRem.exec(note.content)) !== null) { + const isDone = match[1] === 'task-done' + const label = match[3] + const targetStr = match[4] + if (!isDone && targetStr) { + const targetMs = new Date(targetStr).getTime() + if (Date.now() >= targetMs) { + const notifKey = `${note.id}-${targetMs}-${label}` + if (!notified.has(notifKey)) { + console.log('Triggering OS notification for:', label) + new Notification('PaperCache Reminder', { + body: label, + silent: false + }) + notified.add(notifKey) + hasNewNotifs = true + } + } + } + } + }) + + if (hasNewNotifs) { + localStorage.setItem('papercache_notified', JSON.stringify(Array.from(notified))) + } + }, 10000) + + return () => clearInterval(interval) + }, [notes]) const handleAppClick = () => { setShowMainActionMenu(false) @@ -1315,6 +1539,45 @@ function App() { )} + {showRemindersView && ( + setShowRemindersView(false)} + onNavigateToNote={(noteId) => { + const idx = notes.findIndex(n => n.id === noteId) + if (idx !== -1) { + setCurrentNoteIndex(idx) + setShowRemindersView(false) + } + }} + onToggleReminder={(noteId, from, to, insert) => { + setNotes(prevNotes => { + const newNotes = [...prevNotes] + const idx = newNotes.findIndex(n => n.id === noteId) + if (idx !== -1) { + const note = newNotes[idx] + const newContent = note.content.slice(0, from) + insert + note.content.slice(to) + newNotes[idx] = { ...note, content: newContent } + window.electronAPI.saveNote(note.id, newContent) + + // If the note being modified is the currently open note, we need to update the CodeMirror view + // We'll dispatch a custom event that a useEffect in App can listen to, or we can just let + // the `notes` state update handle it. However, the Editor is uncontrolled by `notes` once loaded! + // To safely update the open editor without re-mounting, we dispatch a DOM event. + if (idx === currentNoteIndex) { + const view = editorRef.current?.view + if (view) { + view.dispatch({ changes: { from, to, insert } }) + } + } + } + return newNotes + }) + }} + /> + )} + {showNoteSearch && (() => { const filteredNotes = notes.filter( @@ -1600,6 +1863,18 @@ function App() { > Graph View + + + +
+ {reminders.length === 0 ? ( +
+ + + + +

No tasks found.

+

Type `/task ` in any note to create one.

+
+ ) : ( +
+ {reminders.map((rem, idx) => { + const isOverdue = !rem.done && rem.targetMs && rem.targetMs < now + const isImminent = !rem.done && rem.targetMs && rem.targetMs > now && (rem.targetMs - now) < 60 * 60 * 1000 + + let baseColor = '#7EB8D4' // default + if (!rem.done && rem.targetMs) { + if (isOverdue) baseColor = '#FF3B30' // red + else if (isImminent) baseColor = '#faad14' // orange + } + + + + return ( +
{ + onNavigateToNote(rem.noteId) + onClose() + }} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = itemBgHover)} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')} + style={{ + display: 'flex', + alignItems: 'center', + padding: '12px 16px', + borderRadius: '8px', + border: `1px solid ${borderColor}`, + cursor: 'pointer', + transition: 'background-color 0.2s', + }} + > + { + e.stopPropagation() // Prevent navigation + const from = rem.matchIndex + const to = rem.matchIndex + (rem.done ? 10 : 5) // length of /task-done or /task + const insert = rem.done ? '/task' : '/task-done' + onToggleReminder(rem.noteId, from, to, insert) + }} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '14px', + height: '14px', + border: `2px solid ${baseColor}`, + borderRadius: '50%', + backgroundColor: rem.done ? baseColor : 'transparent', + color: rem.done ? 'white' : 'transparent', + marginRight: '12px', + flexShrink: 0, + cursor: 'pointer' + }} + > + + + + + +
+ + {rem.label} + + + {rem.creationDate && ( + + Created {rem.creationDate} + + )} +
+ + {rem.targetMs && ( +
+ {isOverdue ? 'Overdue: ' : 'Due: '} + {new Date(rem.targetMs).toLocaleString([], { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' + })} +
+ )} +
+ ) + })} +
+ )} +
+ + ) +} + +export default RemindersPage