Better todos

This commit is contained in:
Corey Johnson 2025-07-02 10:22:07 -07:00
parent 6ee2a00f07
commit 8843349413
15 changed files with 525 additions and 187 deletions

View File

@ -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=="],

3
packages/todo/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module "zzfx" {
export function zzfx(...args: any[]): void
}

View File

@ -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,

View File

@ -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
}

View File

@ -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;
}
}

View File

@ -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") },
])
})

View File

@ -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
}

View File

@ -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
}

View File

@ -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<Decoration>()
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
}

View File

@ -83,11 +83,13 @@ export const todoDecorations = (filterRef: RefObject<string>) => {
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

View File

@ -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<string>("")
const filterRef = useRef(filter)
const filterElRef = useRef<HTMLInputElement>(null)
const [showShortcuts, setShowShortcuts] = useState<boolean>(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 (
<div class="grow flex flex-col">
{showShortcuts && <KeyboardShortcuts onClose={() => setShowShortcuts(false)} />}
<input
type="text"
placeholder="Filter by tag"
@ -94,18 +110,44 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
)
}
const KeyboardShortcuts = ({ onClose }: { onClose: () => void }) => {
return (
<div class="fixed inset-0 bg-black bg-opacity-30 z-50" onClick={onClose}>
<div class="fixed inset-0 flex items-center justify-center">
<div class="bg-white rounded-sm p-3 shadow-xl" onClick={(e) => e.stopPropagation()}>
<ul class="grid grid-cols-1 gap-2 text-xs">
{buildKeyBindings(undefined as any)
.filter((k) => !k.hidden)
.map((binding) => (
<li class="grid grid-cols-2 items-center gap-4">
<span>{binding.label}</span>
<div class="flex gap-1 justify-end">
{binding.key.split("-").map((part) => (
<kbd class="bg-gray-200 px-2 py-1 rounded">{part}</kbd>
))}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
)
}
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 <tab>
- [x] Complete a task by pressing <opt+k>
- [ ] You can use a #tag to filter todos
- [ ] A sub task! Create nested todos by indenting with <tab>
- [x] Complete a todo by pressing <opt+k>
- [ ] 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()

View File

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>,
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<HTMLInputElement>,
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
}

View File

@ -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)
})

View File

@ -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) => {

View File

@ -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 })