From 8843349413b5fe909b9a1480713b4ed253c06e3b Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 2 Jul 2025 10:22:07 -0700 Subject: [PATCH] Better todos --- bun.lock | 3 + packages/todo/global.d.ts | 3 + packages/todo/package.json | 3 +- packages/todo/src/autoTodoOnNewline.ts | 68 +++++--- packages/todo/src/index.css | 56 ++++++- packages/todo/src/todo.test.ts | 18 ++- packages/todo/src/todo.ts | 43 ++++-- packages/todo/src/todoClickHandler.ts | 7 + packages/todo/src/todoCompletion.ts | 13 ++ packages/todo/src/todoDecorations.ts | 6 +- packages/todo/src/todoEditor.tsx | 62 ++++++-- packages/todo/src/todoKeymap.ts | 205 ++++++++++++++++++++----- packages/todo/src/todoList.test.ts | 50 +++++- packages/todo/src/todoList.ts | 64 ++++++-- packages/todo/src/todoTimer.ts | 111 +++++-------- 15 files changed, 525 insertions(+), 187 deletions(-) create mode 100644 packages/todo/global.d.ts create mode 100644 packages/todo/src/todoCompletion.ts diff --git a/bun.lock b/bun.lock index 93a5599..c12e6b5 100644 --- a/bun.lock +++ b/bun.lock @@ -109,6 +109,7 @@ "@lezer/lr": "^1.4.2", "hono": "catalog:", "luxon": "^3.6.1", + "zzfx": "^1.3.0", }, "devDependencies": { "@types/bun": "latest", @@ -723,6 +724,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + "zzfx": ["zzfx@1.3.0", "", {}, "sha512-59fM+Oo/0NBmjK0G7Ryo1Zt/2MiIKAhtCiFDbuyopihJgZxFB+Ow9Gdgu77KAdiNSS1E7mMqduXV1hnNkBb/eg=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], diff --git a/packages/todo/global.d.ts b/packages/todo/global.d.ts new file mode 100644 index 0000000..c2d1d5b --- /dev/null +++ b/packages/todo/global.d.ts @@ -0,0 +1,3 @@ +declare module "zzfx" { + export function zzfx(...args: any[]): void +} \ No newline at end of file diff --git a/packages/todo/package.json b/packages/todo/package.json index a6ea830..4a4317e 100644 --- a/packages/todo/package.json +++ b/packages/todo/package.json @@ -14,7 +14,8 @@ "@lezer/highlight": "^1.2.1", "@lezer/lr": "^1.4.2", "hono": "catalog:", - "luxon": "^3.6.1" + "luxon": "^3.6.1", + "zzfx": "^1.3.0" }, "prettier": { "semi": false, diff --git a/packages/todo/src/autoTodoOnNewline.ts b/packages/todo/src/autoTodoOnNewline.ts index e98e344..4b7dc6e 100644 --- a/packages/todo/src/autoTodoOnNewline.ts +++ b/packages/todo/src/autoTodoOnNewline.ts @@ -1,3 +1,4 @@ +import { checkboxRegex } from "@/todo" import { EditorView } from "@codemirror/view" export const autoTodoOnNewline = EditorView.updateListener.of((update) => { @@ -7,31 +8,48 @@ export const autoTodoOnNewline = EditorView.updateListener.of((update) => { if (!tr.isUserEvent("input")) return tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { - if (inserted.toString() !== "\n") return - - const prefix = "- [ ] " - // If previous line was an empty todo, remove the empty line instead of continuing - const insertPos = fromB + inserted.toString().length - const prevLine = update.state.doc.lineAt(insertPos - 1) - - // if the previous line is empty or just whitespace, just insert the newline - if (prevLine.text.trim() === "") { - return - } else if (prevLine.text.trim() === prefix.trim()) { - const start = prevLine.from - const end = insertPos - update.view.dispatch({ - changes: { from: start, to: end, insert: "" }, - selection: { anchor: start }, - userEvent: "delete", - }) - } else { - update.view.dispatch({ - changes: { from: insertPos, to: insertPos, insert: prefix }, - selection: { anchor: insertPos + prefix.length }, - userEvent: "todo.newline", - }) - } + handleNewlineChange({ update, fromA, toA, fromB, toB, inserted }) }) } }) + +type HandleNewlineChangeOpts = { + update: any + fromA: number + toA: number + fromB: number + toB: number + inserted: any +} + +const handleNewlineChange = (opts: HandleNewlineChangeOpts) => { + if (!opts.inserted.toString().match(/\s*\n/)) return + + let input = opts.inserted.toString().replace(/\n/g, "\\n").replace(/\s/g, "\\s") + const insertPos = opts.fromB + opts.inserted.toString().length + const prevLine = opts.update.state.doc.lineAt(insertPos - opts.inserted.toString().length) + + if (prevLine.text.trim() === "") { + return + } else if (endsWith(prevLine.text, checkboxRegex)) { + const start = prevLine.from + const end = insertPos + opts.update.view.dispatch({ + changes: { from: start, to: end, insert: "" }, + selection: { anchor: start }, + userEvent: "delete", + }) + } else { + const prefix = `- [ ] ` + opts.update.view.dispatch({ + changes: { from: insertPos, to: insertPos, insert: prefix }, + selection: { anchor: insertPos + prefix.length }, + userEvent: "todo.newline", + }) + } +} + +const endsWith = (str: string, regex: RegExp) => { + const match = str.match(regex) + return match && match.index === str.length - match[0].length +} diff --git a/packages/todo/src/index.css b/packages/todo/src/index.css index bfc24b1..7125f48 100644 --- a/packages/todo/src/index.css +++ b/packages/todo/src/index.css @@ -1,7 +1,39 @@ -/* Todo styling */ +.todo-completed { + display: inline-block; + animation: todoComplete 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards !important; +} + .todo-completed * { text-decoration: line-through; - color: #D1D5DB !important; + color: black !important; + opacity: 0.5; + +} + +@keyframes todoComplete { + 0% { + opacity: 1; + text-decoration: none; + transform: scale(1) rotate(0deg); + } + 15% { + transform: scale(1.15) rotate(2deg); + } + 30% { + transform: scale(1.1) rotate(-1deg); + text-decoration: line-through; + } + 50% { + transform: scale(1.05) rotate(1deg); + } + 70% { + transform: scale(0.95) rotate(0deg); + } + 100% { + text-decoration: line-through; + opacity: 0.5; + transform: scale(1) rotate(0deg); + } } .todo-tag { @@ -60,7 +92,7 @@ opacity : 0.3; } -.timer-running .cm-line.neat { +.timer-running .cm-line.cm-timer { opacity : 1; } @@ -80,4 +112,22 @@ color: black; padding: 2px 5px; letter-spacing: 1px; +} + +/* blink finished output */ +.countdown-timer.finished { + animation: blink 1s infinite; + color: red; +} + +@keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } } \ No newline at end of file diff --git a/packages/todo/src/todo.test.ts b/packages/todo/src/todo.test.ts index c5c3aaa..f7e725b 100644 --- a/packages/todo/src/todo.test.ts +++ b/packages/todo/src/todo.test.ts @@ -19,15 +19,17 @@ test("parsing valid todos", () => { }) test("todo nodes", () => { - const todo = Todo.parse("- [ ] some #words and @2/2/25") + const todo = Todo.parse(" - [ ] some #words and @2/2/25") expect(todo!.nodes).toEqual([ - { type: "text", content: "some", start: 6 }, - { type: "whitespace", content: " ", start: 10 }, - { type: "tag", content: "#words", start: 11 }, - { type: "whitespace", content: " ", start: 17 }, - { type: "text", content: "and", start: 18 }, - { type: "whitespace", content: " ", start: 21 }, - { type: "date", content: "@2/2/25", parsed: DateTime.fromISO("2025-02-02"), start: 22 }, + { type: "indent", content: " " }, + { type: "checkbox", content: "- [ ] ", checked: false }, + { type: "text", content: "some" }, + { type: "whitespace", content: " " }, + { type: "tag", content: "#words" }, + { type: "whitespace", content: " " }, + { type: "text", content: "and" }, + { type: "whitespace", content: " " }, + { type: "date", content: "@2/2/25", parsed: DateTime.fromISO("2025-02-02") }, ]) }) diff --git a/packages/todo/src/todo.ts b/packages/todo/src/todo.ts index 7a58030..afca489 100644 --- a/packages/todo/src/todo.ts +++ b/packages/todo/src/todo.ts @@ -8,6 +8,8 @@ export type TodoJSON = { children: TodoJSON[] } +export const checkboxRegex = /^\s*-\s*\[\s*(\w?)\s*\]\s*/ + export class Todo { done: boolean indent = "" @@ -33,12 +35,24 @@ export class Todo { return string } + toJSON(): TodoJSON { + return { + done: this.done, + indent: this.indent, + text: this.text, + children: this.children.map((child) => child.toJSON()), + } + } + addChild(todo: Todo) { this.children.push(todo) } get text() { - return this.nodes.map((node) => node.content).join("") + return this.nodes + .filter((node) => node.type !== "indent" && node.type !== "checkbox") + .map((node) => node.content) + .join("") } get timeEstimate() { @@ -55,7 +69,9 @@ export class Todo { } } -type TodoNode = { content: string; start: number } & ( +type TodoNode = { content: string } & ( + | { type: "indent" } + | { type: "checkbox"; checked: boolean } | { type: "whitespace" } | { type: "text" } | { type: "date"; parsed: DateTime } @@ -73,17 +89,22 @@ ${" ".repeat(offset)}^ error occurred here` } const parseTodo = (line: string): Todo | undefined => { + const todoNodes: TodoNode[] = [] + + // Grab the indent at the start of the line const indentMatch = line.match(/^\s+/) const indent = indentMatch?.[0] ?? "" + todoNodes.push({ type: "indent", content: indent }) let offset = indent.length - // Check for status - const match = line.match(/\s*-\s*\[\s*(\w?)\s*\]\s+/i) + // Check for done status + const match = line.slice(offset).match(checkboxRegex) if (!match) return // Invalid format, no status found const done = Boolean(match[1]) - offset = match[0].length + todoNodes.push({ type: "checkbox", content: match[0], checked: done }) + offset += match[0].length - const todoNodes: TodoNode[] = [] + // Parse everything else in the line let previousOffset = -1 while (offset < line.length) { ensure(offset !== previousOffset, parseError(line, offset, `Infinite loop detected.`)) @@ -93,7 +114,7 @@ const parseTodo = (line: string): Todo | undefined => { const whitespaceMatch = line.slice(offset).match(/^\s+/) if (whitespaceMatch) { const content = whitespaceMatch[0] - todoNodes.push({ type: "whitespace", content, start: offset }) + todoNodes.push({ type: "whitespace", content }) offset += content.length continue } @@ -142,7 +163,7 @@ const parseTimeEstimate = (line: string, start: number) => { return parseText(line, start) } - const node: TodoNode = { type: "time-estimate", content: timeMatch![0], seconds, start } + const node: TodoNode = { type: "time-estimate", content: timeMatch![0], seconds } return node } @@ -153,7 +174,7 @@ const parseTag = (line: string, start: number) => { return parseText(line, start) } - const node: TodoNode = { type: "tag", content, start } + const node: TodoNode = { type: "tag", content } return node } @@ -174,7 +195,7 @@ const parseDate = (line: string, start: number) => { const date = DateTime.fromMillis(Date.parse(content.slice(1))) if (date.isValid) { - const node: TodoNode = { type: "date", content, parsed: date, start } + const node: TodoNode = { type: "date", content, parsed: date } return node } else { return parseText(line, start) @@ -186,7 +207,7 @@ const parseText = (line: string, start: number) => { const content = textMatch?.[0] if (!content) return - const node: TodoNode = { type: "text", content, start } + const node: TodoNode = { type: "text", content } return node } diff --git a/packages/todo/src/todoClickHandler.ts b/packages/todo/src/todoClickHandler.ts index 280149e..186c6cd 100644 --- a/packages/todo/src/todoClickHandler.ts +++ b/packages/todo/src/todoClickHandler.ts @@ -1,5 +1,6 @@ import { EditorView } from "@codemirror/view" import { Todo } from "@/todo" +import { triggerTodoCompletionEffect } from "./todoCompletion" export const todoClickHandler = EditorView.domEventHandlers({ click(event, view) { @@ -28,6 +29,7 @@ export const todoClickHandler = EditorView.domEventHandlers({ if (clickOffset >= 0 && clickOffset <= fullMatch.length) { // Toggle the checkbox state const newState = todo.done ? " " : "x" + const isCompleting = !todo.done // Will be completing if currently not done view.dispatch({ changes: { @@ -37,6 +39,11 @@ export const todoClickHandler = EditorView.domEventHandlers({ }, }) + // Add animation effect when completing (not uncompleting) + if (isCompleting) { + triggerTodoCompletionEffect(view, line.from) + } + event.preventDefault() return true } diff --git a/packages/todo/src/todoCompletion.ts b/packages/todo/src/todoCompletion.ts new file mode 100644 index 0000000..4dfed9d --- /dev/null +++ b/packages/todo/src/todoCompletion.ts @@ -0,0 +1,13 @@ +import { RangeSetBuilder } from "@codemirror/state" +import { Decoration, EditorView } from "@codemirror/view" +import { zzfx } from "zzfx" + +export function triggerTodoCompletionEffect(view: EditorView, pos: number) { + const builder = new RangeSetBuilder() + const line = view.state.doc.lineAt(pos) + + builder.add(pos, pos, Decoration.line({ class: "animate-completion" })) + builder.finish() + + zzfx(...[0.8, , 617, 0.02, 0.03, 0.18, , 3, -0.1, , 358, 0.06, -0.01, , , , 0.02, 1.09, 0.05, 0.02, -1213]) // Pickup 58 - Mutation 5 +} diff --git a/packages/todo/src/todoDecorations.ts b/packages/todo/src/todoDecorations.ts index 85e7388..5904a28 100644 --- a/packages/todo/src/todoDecorations.ts +++ b/packages/todo/src/todoDecorations.ts @@ -83,11 +83,13 @@ export const todoDecorations = (filterRef: RefObject) => { const decorationsFor = (todo: Todo) => { const decorations: { type: string; start: number; end: number }[] = [] + let start = 0 for (const node of todo.nodes) { - const { type, start } = node - const end = node.start + node.content.length + const { type } = node + const end = start + node.content.length decorations.push({ type, start, end }) + start = end } return decorations diff --git a/packages/todo/src/todoEditor.tsx b/packages/todo/src/todoEditor.tsx index bd3ac94..996e849 100644 --- a/packages/todo/src/todoEditor.tsx +++ b/packages/todo/src/todoEditor.tsx @@ -6,7 +6,7 @@ import { keymap as viewKeymap } from "@codemirror/view" // ensure keymap is impo import { foldGutter, foldKeymap } from "@codemirror/language" import { refreshFilterEffect, todoDecorations } from "@/todoDecorations" import { autoTodoOnNewline } from "@/autoTodoOnNewline" -import { todoKeymap } from "@/todoKeymap" +import { buildKeyBindings, todoKeymap } from "@/todoKeymap" import { todoClickHandler } from "@/todoClickHandler" import { dateAutocompletion } from "@/dateAutocompletion" import { todoTimer } from "@/todoTimer" @@ -25,6 +25,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { const [filter, setFilter] = useState("") const filterRef = useRef(filter) const filterElRef = useRef(null) + const [showShortcuts, setShowShortcuts] = useState(false) useEffect(() => { filterRef.current = filter @@ -41,7 +42,6 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { } }) - // Use todos prop for content const state = EditorState.create({ doc: defaultValue || defaultDoc, extensions: [ @@ -51,7 +51,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { todoDecorations(filterRef), changeListener, autoTodoOnNewline, - todoKeymap(filterElRef), + todoKeymap(filterElRef, setShowShortcuts), todoClickHandler, dateAutocompletion, todoTimer, @@ -66,7 +66,22 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { editorRef.current = view return () => view.destroy() - }, [onChange]) + }, [onChange, setShowShortcuts]) + + // Handle escape key globally to close shortcuts modal + useEffect(() => { + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape" && showShortcuts) { + setShowShortcuts(false) + e.preventDefault() + } + } + + if (showShortcuts) { + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + } + }, [showShortcuts]) const filterInput = useCallback((e: KeyboardEvent) => { if (e.key === "Enter" || e.key === "Escape") { @@ -80,6 +95,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { return (
+ {showShortcuts && setShowShortcuts(false)} />} { ) } +const KeyboardShortcuts = ({ onClose }: { onClose: () => void }) => { + return ( +
+
+
e.stopPropagation()}> +
    + {buildKeyBindings(undefined as any) + .filter((k) => !k.hidden) + .map((binding) => ( +
  • + {binding.label} +
    + {binding.key.split("-").map((part) => ( + {part} + ))} +
    +
  • + ))} +
+
+
+
+ ) +} + const defaultDoc = ` -# Today (Group tasks by when they are due) +# Today - [ ] Sample task with a due date @${DateTime.local().toFormat("MM/dd/yyyy")} -- [ ] You can use a #tag to filter tasks - - [ ] A sub task! Create nested tasks by indenting with -- [x] Complete a task by pressing +- [ ] You can use a #tag to filter todos + - [ ] A sub task! Create nested todos by indenting with +- [x] Complete a todo by pressing +- [ ] You can also set a time estimate for todos (press the play button to start) 10m # This week -- [ ] Another task with a due date @${DateTime.local().plus({ days: 3 }).toFormat("MM/dd/yyyy")} +- [ ] Another todo with a due date @${DateTime.local().plus({ days: 3 }).toFormat("MM/dd/yyyy")} # Later -- [ ] I use later as a junk drawer for tasks I don't want to forget +- [ ] I use later as a junk drawer for todos I don't want to forget `.trim() diff --git a/packages/todo/src/todoKeymap.ts b/packages/todo/src/todoKeymap.ts index cf39cd8..0215249 100644 --- a/packages/todo/src/todoKeymap.ts +++ b/packages/todo/src/todoKeymap.ts @@ -1,50 +1,183 @@ import { indentMore, indentLess } from "@codemirror/commands" -import { EditorView, keymap } from "@codemirror/view" +import { EditorView, keymap, type KeyBinding } from "@codemirror/view" import { Todo } from "./todo" import { type RefObject } from "hono/jsx" +import { parseTodoList, todoListToString } from "@/todoList" +import { todoTimer } from "./todoTimer" +import { triggerTodoCompletionEffect } from "./todoCompletion" -export const todoKeymap = (filterElRef: RefObject) => { - return keymap.of([ - { - key: "alt-l", // Focus the filter input - preventDefault: true, - run: (_view: EditorView) => { - filterElRef.current?.focus() - return true - }, - }, - { - key: "alt-k", // toggle done state of the todo item - preventDefault: true, - run: (view: EditorView) => { - const { state } = view - const { head } = state.selection.main - const line = state.doc.lineAt(head) +export type Command = KeyBinding & { label: string; key: string; hidden?: boolean } - const todo = Todo.parse(line.text) - if (!todo) return false - todo.done = !todo.done +export const buildKeyBindings = ( + filterElRef: RefObject, + setShowShortcuts?: (show: boolean) => void +): Command[] => [ + { + label: "Insert empty", + key: "alt-h", + preventDefault: true, + run: (view: EditorView) => { + // if the line is empty, insert a new todo item + const { state } = view + const { head } = state.selection.main + const line = state.doc.lineAt(head) - const from = line.from - const to = line.to - const offset = head - from + if (line.text.trim() === "") { + const newTodo = "- [ ] " view.dispatch({ - changes: { from, to, insert: todo.toString() }, - selection: { anchor: from + offset }, + changes: { from: line.from, to: line.to, insert: newTodo }, + selection: { anchor: line.to + newTodo.length }, userEvent: "input", }) + } else { + // otherwise insert a new empty line + view.dispatch({ + changes: { from: head, to: head, insert: "\n" }, + selection: { anchor: head + 1 }, + userEvent: "input", + }) + } + + return true + }, + }, + { + label: "Show keyboard shortcuts", + key: "alt-/", + preventDefault: true, + run: (_view: EditorView) => { + setShowShortcuts?.(true) + return true + }, + }, + { + label: "Toggle done state of the todo item", + key: "alt-j", + preventDefault: true, + run: (view: EditorView) => toggleDone(view), + }, + { + label: "Toggle the todo timer", + key: "alt-Enter", + preventDefault: true, + run: (view: EditorView) => { + const timerPlugin = view.plugin(todoTimer) + if (timerPlugin?.currentWidget) { + timerPlugin.currentWidget.toggleTimer() return true - }, + } + return false }, - { - key: "Tab", - preventDefault: true, - run: indentMore, + }, + { + label: "Move all done todos to # Done", + key: "alt-k", + preventDefault: true, + run: (view: EditorView) => { + moveToDone(view) + return true }, - { - key: "Shift-Tab", - preventDefault: true, - run: indentLess, + }, + { + label: "Stop the todo timer", + key: "Escape", + hidden: true, + run: (view: EditorView) => { + const timerPlugin = view.plugin(todoTimer) + if (timerPlugin?.currentWidget) { + timerPlugin.currentWidget.stopTimer() + } + return false }, - ]) + }, + { + label: "Focus the filter input", + key: "alt-l", + preventDefault: true, + run: (_view: EditorView) => { + filterElRef.current?.focus() + return true + }, + }, + { + label: "Indent the todo item", + key: "Tab", + preventDefault: true, + run: indentMore, + }, + { + label: "Unindent the todo item", + key: "Shift-Tab", + preventDefault: true, + run: indentLess, + }, +] + +export const todoKeymap = ( + filterElRef: RefObject, + setShowShortcuts?: (show: boolean) => void +) => { + return keymap.of(buildKeyBindings(filterElRef, setShowShortcuts)) +} + +const moveToDone = (view: EditorView) => { + const todoList = parseTodoList(view.state.doc.toString()) + let doneHeader = todoList.find((header) => header.title === "# Done") + + if (!doneHeader) { + doneHeader = { title: "# Done", todos: [] } + todoList.push(doneHeader) + } + + // collect and remove all done todos + const doneTodos: Todo[] = [] + todoList.forEach((header) => { + header.todos = header.todos.filter((todo) => { + if (typeof todo === "string" || !todo.done) return true + + doneTodos.push(todo) + return false + }) + }) + + doneHeader.todos.push(...doneTodos) + + const updatedList = todoListToString(todoList).trimEnd() + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: updatedList }, + selection: { anchor: 0 }, + userEvent: "input", + }) +} + +const toggleDone = (view: EditorView) => { + const { state } = view + const { head } = state.selection.main + const line = state.doc.lineAt(head) + + const todo = Todo.parse(line.text) + if (!todo) return false + + todo.done = !todo.done + + const timerPlugin = view.plugin(todoTimer) + if (timerPlugin?.currentWidget) { + timerPlugin.currentWidget.stopTimer() + } + + const from = line.from + const to = line.to + const offset = head - from + view.dispatch({ + changes: { from, to, insert: todo.toString() }, + selection: { anchor: from + offset }, + userEvent: "input", + }) + + // Add animation and sound effect when completing (not uncompleting) + if (todo.done) { + triggerTodoCompletionEffect(view, line.from) + } + + return true } diff --git a/packages/todo/src/todoList.test.ts b/packages/todo/src/todoList.test.ts index 8d4157a..35b7b04 100644 --- a/packages/todo/src/todoList.test.ts +++ b/packages/todo/src/todoList.test.ts @@ -1,17 +1,17 @@ -import { todoListToString, type TodoList } from "@/todoList" +import { parseTodoList, todoListToString, type TodoList } from "@/todoList" import { test, expect } from "bun:test" test("todoListToString", () => { const todoList: TodoList = [ { - title: "Today", + title: "# Today", todos: [ { done: false, indent: "", text: "a task with a #tag", children: [] }, { done: true, indent: "", text: "a #tag and date @2/2/25 in a task", children: [] }, ], }, { - title: "Tomorrow", + title: "# Tomorrow", todos: [ { done: false, @@ -41,3 +41,47 @@ test("todoListToString", () => { expect(result).toEqual(expected) }) + +test("parseTodoList", () => { + const todoListString = `# Today +- [ ] a task with a #tag +- [x] a #tag and date @2/2/25 in a task + +# Tomorrow +- [ ] another task + - [ ] a subtask + - [x] completed + +- [ ] a task with a @1/2/25 date` + + const result = parseTodoList(todoListString) + + const expected: TodoList = [ + { + title: "# Today", + todos: [ + { done: false, indent: "", text: "a task with a #tag", children: [] }, + { done: true, indent: "", text: "a #tag and date @2/2/25 in a task", children: [] }, + "", + ], + }, + { + title: "# Tomorrow", + todos: [ + { + done: false, + indent: "", + text: "another task", + children: [ + { done: false, indent: " ", text: "a subtask", children: [] }, + { done: true, indent: " ", text: "completed", children: [] }, + ], + }, + " ", + { done: false, indent: "", text: "a task with a @1/2/25 date", children: [] }, + ], + }, + ] + + expect(JSON.parse(JSON.stringify(result))).toEqual(expected) +}) diff --git a/packages/todo/src/todoList.ts b/packages/todo/src/todoList.ts index 773515a..9168710 100644 --- a/packages/todo/src/todoList.ts +++ b/packages/todo/src/todoList.ts @@ -1,33 +1,71 @@ -import type { TodoJSON } from "@/todo" +import { Todo, type TodoJSON } from "@/todo" +import { ensure } from "@workshop/shared/utils" -export type TodoList = { +type TodoHeader = { title?: string - todos: TodoOrString[] -}[] + todos: (Todo | string)[] +} -export type TodoOrString = TodoJSON | string +export type TodoList = TodoHeader[] + +export const parseTodoList = (input: string): TodoList => { + const lines = input.split("\n") + const todoList: TodoList = [] + let currentHeader: TodoHeader = { todos: [] } + let parentStack: Todo[] = [] + + lines.forEach((line) => { + if (line.startsWith("#")) { + currentHeader = { title: line, todos: [] } + todoList.push(currentHeader) + } else { + const todo = Todo.parse(line) + + if (todo) { + for (let i = parentStack.length - 1; i >= 0; i--) { + const parent = parentStack[i] + ensure(parent, `Parent at index ${i} should not be undefined`) + + if (todo.indent.length > parent.indent.length) { + parent.addChild(todo) + parentStack.push(todo) + return + } + } + + currentHeader.todos.push(todo) + parentStack = [todo] + return + } else { + currentHeader.todos.push(line) + } + } + }) + + return todoList +} export const todoListToString = (todoList: TodoList): string => { let output = "" todoList.forEach((header) => { if (header.title) { - output += `# ${header.title}\n` + output += `${header.title}\n` } - header.todos.forEach((todoJson) => { - output += todoJsonToString(todoJson) + header.todos.forEach((todo) => { + if (typeof todo === "string") { + output += `${todo}\n` + } else { + output += todoJsonToString(todo) + } }) }) return output } -const todoJsonToString = (todoOrString: TodoOrString): string => { - if (typeof todoOrString === "string") { - return `${todoOrString}\n` - } - +const todoJsonToString = (todoOrString: Todo): string => { let result = `${todoOrString.indent}- [${todoOrString.done ? "x" : " "}] ${todoOrString.text}\n` todoOrString.children.forEach((child) => { diff --git a/packages/todo/src/todoTimer.ts b/packages/todo/src/todoTimer.ts index b7d52cc..5e6ca69 100644 --- a/packages/todo/src/todoTimer.ts +++ b/packages/todo/src/todoTimer.ts @@ -1,6 +1,7 @@ import { EditorView, ViewPlugin, ViewUpdate, WidgetType, Decoration } from "@codemirror/view" import { RangeSetBuilder } from "@codemirror/state" import { Todo } from "@/todo" +import { zzfx } from "zzfx" class CountdownWidget extends WidgetType { duration: number @@ -24,17 +25,31 @@ class CountdownWidget extends WidgetType { } } + stopTimer() { + this.view.dom.classList.remove("timer-running") + + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = undefined + } + this.startedAt = undefined + } + + toggleTimer() { + if (this.startedAt) { + this.updateDOM(this.toDOM(), this.view) + this.stopTimer() + } else { + this.startTimer() + } + } + toDOM(): HTMLElement { const span = document.createElement("span") span.className = "countdown-timer" span.addEventListener("mousedown", (e) => { - if (this.startedAt) { - this.stopTimer() - span.textContent = "▶️" - } else { - this.startTimer() - } + this.toggleTimer() }) const updateTimer = () => { @@ -45,7 +60,7 @@ class CountdownWidget extends WidgetType { } if (!this.startedAt) { - span.textContent = "▶️" + span.textContent = "▶" return } @@ -53,13 +68,21 @@ class CountdownWidget extends WidgetType { const remaining = this.duration - elapsed if (remaining <= 0) { - span.textContent = "⏰ TIME'S UP!" - + span.textContent = "00:00" + span.classList.remove("running") + span.classList.add("finished") this.stopTimer() + zzfx(...[1.2, , 117, 0.03, 0.01, 0.12, , 2.1, , , , , , 1.3, , 0.3, 0.08, 0.6, 0.1, , 1946]) // Hit 107 + this.showNotification() + return } const seconds = Math.ceil(remaining / 1000) - span.textContent = `${seconds.toString().padStart(2, "0")}s` + const minutes = Math.floor(seconds / 60) + const secondsRemainder = seconds % 60 + span.textContent = `${minutes.toString().padStart(2, "0")}:${secondsRemainder + .toString() + .padStart(2, "0")}` } updateTimer() @@ -68,19 +91,6 @@ class CountdownWidget extends WidgetType { return span } - stopTimer() { - this.view.dom.classList.remove("timer-running") - - this.playAlert() - this.showNotification() - - if (this.intervalId) { - clearInterval(this.intervalId) - this.intervalId = undefined - } - this.startedAt = undefined - } - private async showNotification() { if (Notification.permission === "default") { console.log("Requesting notification permission...") @@ -99,45 +109,6 @@ class CountdownWidget extends WidgetType { } } - private playAlert() { - try { - // Create a more noticeable beep with vibrato - const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() - const oscillator = audioContext.createOscillator() - const gainNode = audioContext.createGain() - const vibratoOscillator = audioContext.createOscillator() - const vibratoGain = audioContext.createGain() - - // Set up vibrato (frequency modulation) - vibratoOscillator.frequency.setValueAtTime(6, audioContext.currentTime) // 6Hz vibrato - vibratoGain.gain.setValueAtTime(30, audioContext.currentTime) // Vibrato depth - vibratoOscillator.connect(vibratoGain) - vibratoGain.connect(oscillator.frequency) - - // Main oscillator setup - oscillator.connect(gainNode) - gainNode.connect(audioContext.destination) - - // Higher frequency for more attention-grabbing sound - oscillator.frequency.setValueAtTime(1000, audioContext.currentTime) - oscillator.type = "sine" - - // Louder volume and longer duration (3 seconds) - gainNode.gain.setValueAtTime(0.5, audioContext.currentTime) - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 3) - - // Start both oscillators - vibratoOscillator.start(audioContext.currentTime) - oscillator.start(audioContext.currentTime) - - // Stop after 3 seconds - vibratoOscillator.stop(audioContext.currentTime + 3) - oscillator.stop(audioContext.currentTime + 3) - } catch (error) { - console.log("Could not play alert sound:", error) - } - } - override destroy() { if (this.intervalId) { clearInterval(this.intervalId) @@ -153,16 +124,6 @@ export const todoTimer = ViewPlugin.fromClass( constructor(view: EditorView) { this.decorations = this.buildDecorations(view) - this.setupKeyHandler(view) - } - - setupKeyHandler(view: EditorView) { - view.dom.addEventListener("keydown", (e) => { - if (e.altKey && e.key === "Enter") { - e.preventDefault() - this.currentWidget?.startTimer() - } - }) } update(update: ViewUpdate) { @@ -177,13 +138,13 @@ export const todoTimer = ViewPlugin.fromClass( const { doc, selection } = view.state const line = doc.lineAt(selection.main.head) - builder.add(line.from, line.from, Decoration.line({ class: "neat" })) - const todo = Todo.parse(line.text) - if (!todo?.timeEstimate) { + if (!todo?.timeEstimate || todo.done) { + this.currentWidget = undefined return builder.finish() } + builder.add(line.from, line.from, Decoration.line({ class: "cm-timer" })) this.currentWidget = new CountdownWidget(todo.timeEstimate, view) const decoration = Decoration.widget({ widget: this.currentWidget, side: 1 })