This commit is contained in:
Corey Johnson 2025-06-25 15:27:14 -07:00
parent 73691aacae
commit 44eca2fc6c
8 changed files with 430 additions and 322 deletions

View File

@ -21,11 +21,6 @@ export const runCronJobs = async (client: Client) => {
return reminderDueDate <= nextDueDate
})
console.log(
`🌭 Checking for reminders due between now and ${nextDueDate.toISO()}, found ${
reminders.length
} total reminders.`
)
if (upcomingReminders.length > 0) {
console.log(`Found ${upcomingReminders.length} upcoming reminders to notify.`)
const content = `These reminders are due soon, let them know!: ${JSON.stringify(upcomingReminders)}`

View File

@ -1,47 +1,38 @@
import { autocompletion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete"
import { DateTime } from "luxon"
// Helper function to get date options
function getDateOptions() {
const getDateOptions = () => {
const today = DateTime.local()
// Helper to create option with formatted date
const createDateOption = (label: string, date: DateTime) => {
const formatted = date.toFormat("M/dd/yyyy")
return {
label,
detail: formatted,
apply: formatted,
}
const detail = date.toFormat("ccc, LLL d")
const apply = date.toFormat("M/dd/yyyy")
return { label: `${label}: `, detail, apply }
}
const options = [createDateOption("today", today), createDateOption("tomorrow", today.plus({ days: 1 }))]
const options = [
createDateOption("today", today),
createDateOption("tomorrow", today.plus({ days: 1 })),
createDateOption("next week", today.plus({ days: 7 })),
createDateOption("next month", today.plus({ months: 1 })),
]
// Add remaining days of the current week (starting from day after tomorrow)
const currentWeekday = today.weekday // 1 = Monday, 7 = Sunday
const weekdayNames = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
for (let i = currentWeekday + 2; i <= 7; i++) {
const daysToAdd = i - currentWeekday
const date = today.plus({ days: daysToAdd })
const weekdayName = weekdayNames[i - 1]
if (weekdayName) {
options.push(createDateOption(weekdayName, date))
}
// Add the next 5 days of the week
for (let i = 1; i <= 6; i++) {
const nextDay = today.plus({ days: i })
const nameIndex = (currentWeekday + i - 1) % 7
options.push(createDateOption(weekdayNames[nameIndex]!, nextDay))
}
// Add next week (next Monday)
const nextMonday = today.plus({ days: 8 - currentWeekday })
options.push(createDateOption("next week", nextMonday))
// Add next month
options.push(createDateOption("next month", today.plus({ months: 1 })))
return options
}
// Completion function that triggers after @
function dateCompletion(context: CompletionContext): CompletionResult | null {
const dateCompletion = (context: CompletionContext) => {
const { state, pos } = context
const line = state.doc.lineAt(pos)
const lineText = line.text
@ -61,7 +52,7 @@ function dateCompletion(context: CompletionContext): CompletionResult | null {
// Only complete if we found @ and cursor is right after it or after some text following @
if (atPos === -1) {
return null
return
}
const textAfterAt = lineText.slice(atPos + 1, linePos)

View File

@ -1,3 +1,2 @@
export { TodoEditor } from "@/todoEditor"
export { parseTodoLine, parseTodoList } from "@/todo"
export type { Todo } from "@/todo"
export { Todo } from "@/todo"

View File

@ -0,0 +1,171 @@
import { parseTodoList, parseTodoLine, Todo, TodoMeta } from "./todo"
import { test, expect } from "bun:test"
test("parse things", () => {
check("- [ ] some words and that is it", {
done: false,
text: "some words and that is it",
tags: [],
children: [],
header: undefined,
})
check("- [x] some words", {
done: true,
text: "some words",
tags: [],
children: [],
header: undefined,
})
check("- [ ] some words -and - [ ] not another task", {
done: false,
text: "some words -and - [ ] not another task",
tags: [],
children: [],
header: undefined,
})
check("- [ ] a task with a #tag", {
done: false,
text: "a task with a #tag",
tags: ["tag"],
children: [],
header: undefined,
})
check("- [ ] a #tag in a task", {
done: false,
text: "a #tag in a task",
tags: ["tag"],
children: [],
header: undefined,
})
check("- [ ] a #tag and #another.", {
done: false,
text: "a #tag and #another.",
tags: ["tag", "another"],
children: [],
header: undefined,
})
check("- [ ] a word with a po#nd sign", {
done: false,
text: "a word with a po#nd sign",
tags: [],
children: [],
header: undefined,
})
check("- [ ] a date @1/2/25!", {
done: false,
text: "a date @1/2/25!",
tags: [],
dueDate: "2025-01-02",
children: [],
header: undefined,
})
check("- [ ] a date @2-2-25!", {
done: false,
text: "a date @2-2-25!",
tags: [],
dueDate: "2025-02-02",
children: [],
header: undefined,
})
check("- [ ] a @1/2/25 date and #tag", {
done: false,
text: "a @1/2/25 date and #tag",
tags: ["tag"],
dueDate: "2025-01-02",
children: [],
header: undefined,
})
check(" ", [])
// Invalid formats
check("- [x status error", [])
check("- [. ] status error", [])
check("just text", [])
// A full todo list
check(
`
# 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
`,
[
{
done: false,
text: "a task with a #tag",
tags: ["tag"],
dueDate: undefined,
children: [],
header: "Today",
},
{
done: true,
text: "a #tag and date @2/2/25 in a task",
tags: ["tag"],
dueDate: "2025-02-02",
children: [],
header: "Today",
},
{
done: false,
text: "another task",
tags: [],
dueDate: undefined,
children: [
{ done: false, text: "a subtask", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
{ done: true, text: "completed", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
],
header: "Tomorrow",
},
{
done: false,
text: "a task with a @1/2/25 date",
tags: [],
dueDate: "2025-01-02",
children: [],
header: "Tomorrow",
},
]
)
})
test("parse metadata positions", () => {
const line = "- [ ] do #tag @1/2/25"
const parsed = parseTodoLine(line)
expect(parsed, line).toBeDefined()
const { meta } = parsed as { todo: Todo; meta: TodoMeta }
// '#tag' starts at index 9, ends at 9 + 4
expect(meta.tags).toEqual([{ start: 7, end: 12 }])
// '+1/2/25' starts at index 14, ends at 14 + 6 + 1 = 21
expect(meta.dates).toEqual([{ start: 12, end: 18 }])
})
const check = (input: string, expectation: Todo | Todo[]) => {
const result = parseTodoList(input)
if (expectation === undefined) {
expect(result, input).toBeUndefined()
} else {
expect(result, input).toEqual(Array.isArray(expectation) ? expectation : [expectation])
}
}

View File

@ -1,171 +1,66 @@
import { parseTodoList, parseTodoLine, Todo, TodoMeta } from "./todo"
import { DateTime } from "luxon"
import { Todo } from "./todo"
import { test, expect } from "bun:test"
test("parse things", () => {
check("- [ ] some words and that is it", {
done: false,
text: "some words and that is it",
tags: [],
children: [],
header: undefined,
})
check("- [x] some words", {
test("parsing valid todos", () => {
expectTodo("- [ ] some words and that is it", { done: false, text: "some words and that is it" })
expectTodo("- [x] has #some #tags and @2/5/25", {
done: true,
text: "some words",
tags: [],
children: [],
header: undefined,
text: "has #some #tags and @2/5/25",
tags: ["some", "tags"],
dueDate: DateTime.fromISO("2025-02-05"),
})
check("- [ ] some words -and - [ ] not another task", {
done: false,
text: "some words -and - [ ] not another task",
tags: [],
children: [],
header: undefined,
expectTodo("- [ ] works with iso dates @2055/01/2", {
text: "works with iso dates @2055/01/2",
dueDate: DateTime.fromISO("2055-01-02"),
})
check("- [ ] a task with a #tag", {
done: false,
text: "a task with a #tag",
tags: ["tag"],
children: [],
header: undefined,
})
check("- [ ] a #tag in a task", {
done: false,
text: "a #tag in a task",
tags: ["tag"],
children: [],
header: undefined,
})
check("- [ ] a #tag and #another.", {
done: false,
text: "a #tag and #another.",
tags: ["tag", "another"],
children: [],
header: undefined,
})
check("- [ ] a word with a po#nd sign", {
done: false,
text: "a word with a po#nd sign",
tags: [],
children: [],
header: undefined,
})
check("- [ ] a date @1/2/25!", {
done: false,
text: "a date @1/2/25!",
tags: [],
dueDate: "2025-01-02",
children: [],
header: undefined,
})
check("- [ ] a date @2-2-25!", {
done: false,
text: "a date @2-2-25!",
tags: [],
dueDate: "2025-02-02",
children: [],
header: undefined,
})
check("- [ ] a @1/2/25 date and #tag", {
done: false,
text: "a @1/2/25 date and #tag",
tags: ["tag"],
dueDate: "2025-01-02",
children: [],
header: undefined,
})
check(" ", [])
// Invalid formats
check("- [x status error", [])
check("- [. ] status error", [])
check("just text", [])
// A full todo list
check(
`
# 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
`,
[
{
done: false,
text: "a task with a #tag",
tags: ["tag"],
dueDate: undefined,
children: [],
header: "Today",
},
{
done: true,
text: "a #tag and date @2/2/25 in a task",
tags: ["tag"],
dueDate: "2025-02-02",
children: [],
header: "Today",
},
{
done: false,
text: "another task",
tags: [],
dueDate: undefined,
children: [
{ done: false, text: "a subtask", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
{ done: true, text: "completed", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
],
header: "Tomorrow",
},
{
done: false,
text: "a task with a @1/2/25 date",
tags: [],
dueDate: "2025-01-02",
children: [],
header: "Tomorrow",
},
]
)
expectTodo(" - [ ] handles nested todos", { text: "handles nested todos", indent: " " })
})
test("parse metadata positions", () => {
const line = "- [ ] do #tag @1/2/25"
const parsed = parseTodoLine(line)
expect(parsed, line).toBeDefined()
const { meta } = parsed as { todo: Todo; meta: TodoMeta }
// '#tag' starts at index 9, ends at 9 + 4
expect(meta.tags).toEqual([{ start: 7, end: 12 }])
// '+1/2/25' starts at index 14, ends at 14 + 6 + 1 = 21
expect(meta.dates).toEqual([{ start: 12, end: 18 }])
test("todo nodes", () => {
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 },
])
})
const check = (input: string, expectation: Todo | Todo[]) => {
const result = parseTodoList(input)
test("parsing invalid todos", () => {
expectInvalidTodo("- [ ]")
expectInvalidTodo(" -[x")
expectInvalidTodo("-[omg] some text")
})
if (expectation === undefined) {
expect(result, input).toBeUndefined()
} else {
expect(result, input).toEqual(Array.isArray(expectation) ? expectation : [expectation])
// Helpers
test("todo to string", () => {
const todo = Todo.parse("- [ ] some #words and @2/2/25")
expect(todo!.toString()).toBe("- [ ] some #words and @2/2/25")
const nestedTodo = Todo.parse(" - [x] more #words and @2/2/25")
expect(nestedTodo!.toString()).toBe(" - [x] more #words and @2/2/25")
})
const expectTodo = (input: string, expectation: Partial<Todo>) => {
const todo = Todo.parse(input)
if (!todo) {
throw new Error(`Failed to parse todo line: "${input}"`)
}
for (const key in expectation) {
const typedKey = key as keyof Todo
const expectedValue = expectation[typedKey]
expect(todo[typedKey], `Unexpected "${typedKey}" for todo "${input}"`).toEqual(expectedValue!)
}
}
const expectInvalidTodo = (input: string) => {
test(`parse invalid todo: ${input}`, () => {
const todo = Todo.parse(input)
expect(todo, `Expected parsing to fail for: "${input}"`).toBeUndefined()
})
}

View File

@ -1,137 +1,179 @@
import { ensure } from "@workshop/shared/utils"
import { DateTime } from "luxon"
export type Todo = {
export class Todo {
done: boolean
text: string
tags: string[]
dueDate?: string
children: Todo[]
header?: string
nested?: boolean
}
nodes: TodoNode[]
indent = ""
export type TodoMeta = {
type: "tag" | "date"
start: number
end: number
}[]
export const parseTodoList = (text: string, zone = "America/Los_Angeles") => {
const todos: Todo[] = []
const lines = text.split("\n")
let currentParent: Todo | undefined
let currentHeader
for (const line of lines) {
const headerMatch = line.match(/^\s*#+\s*(.*)/)
if (headerMatch) {
currentParent = undefined
currentHeader = headerMatch[1]!.trim()
continue
} else if (line.trim() === "") {
continue
}
const { todo } = parseTodoLine(line, currentHeader, zone)
if (todo) {
if (todo.nested) {
if (currentParent) {
currentParent.children.push(todo)
} else {
todos.push(todo) // If there's no parent, treat it as a top-level todo
}
} else {
todos.push(todo)
currentParent = todo
}
} else {
// console.warn(`❌ Skipping invalid todo line "${line}"`)
}
}
return todos
}
export const todoToText = (todo: Todo): string => {
let indent = ""
if (todo.nested) {
indent += " " // Indent for nested todos
}
const text = `${indent}- [${todo.done ? "x" : " "}] ${todo.text}`
return text
}
const invalidLine = { todo: undefined, meta: undefined } as const
export const parseTodoLine = (
line: string,
header?: string,
zone = "America/Los_Angeles"
): { todo: Todo; meta: TodoMeta } | typeof invalidLine => {
const meta: TodoMeta = []
let offset = 0
const todo: Todo = {
done: false,
text: "",
tags: [],
dueDate: undefined,
children: [],
header,
nested: isSubtodo(line),
static parse(line: string) {
return parseTodo(line)
}
offset += line.search(/\S/)
line = line.trimStart()
constructor(nodes: TodoNode[], done: boolean, indent = "") {
this.nodes = nodes
this.done = done
this.indent = indent
}
toString() {
let string = `${this.indent}- [${this.done ? "x" : " "}] ${this.text}`
return string
}
get text() {
return this.nodes.map((node) => node.content).join("")
}
get tags() {
return this.nodes.filter((node) => node.type === "tag").map((node) => node.content.slice(1))
}
get dueDate() {
const dateNode = this.nodes.find((node) => node.type === "date")
return dateNode && dateNode.parsed
}
}
type TodoNode = { content: string; start: number } & (
| { type: "whitespace" }
| { type: "text" }
| { type: "date"; parsed: DateTime }
| { type: "tag" }
)
const parseError = (line: string, offset: number, message?: string): string => {
const errorMessage = message
? `Failed to parse node at offset ${offset}`
: `Failed to parse node at offset ${offset}`
return `${errorMessage}
${line}
${" ".repeat(offset)}^ error occurred here`
}
const parseTodo = (line: string): Todo | undefined => {
const indentMatch = line.match(/^\s+/)
const indent = indentMatch?.[0] ?? ""
let offset = indent.length
// Check for status
const match = line.match(/- \[\s*(\w?)\s*\]\s+/i)
if (!match) return invalidLine // Invalid format, no status found
todo.done = Boolean(match[1])
offset += match[0].length
line = line.slice(match[0].length).trimStart()
const match = line.match(/\s*-\s*\[\s*(\w?)\s*\]\s+/i)
if (!match) return // Invalid format, no status found
const done = Boolean(match[1])
offset = match[0].length
// Save text
todo.text = line
const todoNodes: TodoNode[] = []
let previousOffset = -1
while (offset < line.length) {
ensure(offset !== previousOffset, parseError(line, offset, `Infinite loop detected.`))
previousOffset = offset
// Extract tags
todo.text.matchAll(/(?:^|\s)#(\w+)/g).forEach((match) => {
const start = match.index! + offset
const end = start + match[0].length
meta.push({ type: "tag", start, end })
todo.tags.push(match[1]!)
})
// Extract due date in Month/Day/Year
const dateMatches = todo.text.matchAll(/(?:^|\s)@([\d\/\-]{6,})/g)
for (const match of dateMatches) {
let [month, day, year] = match[1]!.split(/[\/-]/)
if (!month || !day || !year) continue
if (year.length == 2) year = `20${year}` // Assume 21st century for two-digit years
const dateTime = DateTime.fromObject(
{
year: parseInt(year, 10),
month: parseInt(month, 10),
day: parseInt(day, 10),
},
{ zone }
)
if (dateTime.isValid) {
const start = match.index + offset
const end = start + match[0].length
meta.push({ type: "date", start, end })
todo.dueDate = dateTime.toISODate()
break
} else {
console.warn(`🕰️ Invalid date format "${match[1]}"`)
// eat whitespace
const whitespaceMatch = line.slice(offset).match(/^\s+/)
if (whitespaceMatch) {
const content = whitespaceMatch[0]
todoNodes.push({ type: "whitespace", content, start: offset })
offset += content.length
continue
}
// eat text
const isTag = line.slice(offset).startsWith("#")
const isDate = line.slice(offset).startsWith("@")
let node: TodoNode | undefined
if (isTag) {
node = parseTag(line, offset)
} else if (isDate) {
node = parseDate(line, offset)
} else {
node = parseText(line, offset)
}
ensure(node, parseError(line, offset))
todoNodes.push(node)
offset += node.content.length
}
meta.sort((a, b) => a.start - b.start) // CodeMiror expects metadata to be sorted by start position
return { todo, meta }
return new Todo(todoNodes, done, indent)
}
const isSubtodo = (line: string) => {
return /^\s+/.test(line)
const parseTag = (line: string, start: number) => {
const tagMatch = line.slice(start).match(/^#(\w+)/)
const content = tagMatch?.[0]
if (!content) {
return parseText(line, start)
}
const node: TodoNode = { type: "tag", content, start }
return node
}
const parseDate = (line: string, start: number) => {
const dateMatch = line.slice(start).match(/^@([\d\/\-]{6,})/)
const content = dateMatch?.[0]
if (!content) {
return parseText(line, start)
}
let [month, day, year] = content.slice(1).split(/[\/-]/) || []
if (!month || !day || !year) {
return parseText(line, start)
}
const date = DateTime.fromMillis(Date.parse(content.slice(1)))
if (date.isValid) {
const node: TodoNode = { type: "date", content, parsed: date, start }
return node
} else {
return parseText(line, start)
}
}
const parseText = (line: string, start: number) => {
const textMatch = line.slice(start).match(/^\S+/)
const content = textMatch?.[0]
if (!content) return
const node: TodoNode = { type: "text", content, start }
return node
}
// export const parseTodoList = (text: string, zone = defaultZone) => {
// const todos: Todo[] = []
// const lines = text.split("\n")
// let currentParent: Todo | undefined
// let currentHeader
// for (const line of lines) {
// const headerMatch = line.match(/^\s*#+\s*(.*)/)
// if (headerMatch) {
// currentParent = undefined
// currentHeader = headerMatch[1]!.trim()
// continue
// } else if (line.trim() === "") {
// continue
// }
// const { todo } = parseTodoLine(line, currentHeader, zone)
// if (todo) {
// if (todo.nested) {
// if (currentParent) {
// currentParent.children.push(todo)
// } else {
// todos.push(todo) // If there's no parent, treat it as a top-level todo
// }
// } else {
// todos.push(todo)
// currentParent = todo
// }
// } else {
// // console.warn(`❌ Skipping invalid todo line "${line}"`)
// }
// }
// return todos
// }

View File

@ -1,4 +1,4 @@
import { parseTodoLine, type Todo } from "@/todo"
import { Todo } from "@/todo"
import { RangeSetBuilder, StateEffect } from "@codemirror/state"
import { EditorView, Decoration, ViewPlugin, ViewUpdate } from "@codemirror/view"
import { type RefObject } from "hono/jsx"
@ -33,7 +33,7 @@ export const todoDecorations = (filterRef: RefObject<string>) => {
while (pos <= to) {
const line = doc.lineAt(pos)
const text = line.text
const { todo, meta } = parseTodoLine(text, "")
const todo = Todo.parse(text)
if (todo) {
if (filterRef.current && !todo.tags.find((t) => t.startsWith(filterRef.current!))) {
@ -46,8 +46,7 @@ export const todoDecorations = (filterRef: RefObject<string>) => {
if (todo.dueDate) {
// Get number of days until due date
const dueDate = DateTime.fromISO(todo.dueDate, { zone: "America/Los_Angeles" })
const daysUntilDue = dueDate.diffNow("days").days
const daysUntilDue = todo.dueDate.diffNow("days").days
if (daysUntilDue <= -1) {
builder.add(line.from, line.to, Decoration.mark({ class: "todo-overdue" }))
} else if (daysUntilDue <= 0) {
@ -55,7 +54,7 @@ export const todoDecorations = (filterRef: RefObject<string>) => {
}
}
for (const { type, start, end } of meta) {
for (const { type, start, end } of decorationsFor(todo)) {
builder.add(line.from + start, line.from + end, Decoration.mark({ class: `todo-${type}` }))
}
} else if (/^\s*#+\s/.test(text)) {
@ -73,3 +72,20 @@ export const todoDecorations = (filterRef: RefObject<string>) => {
{ decorations: (v) => v.decorations }
)
}
const decorationsFor = (todo: Todo) => {
const decorations: { type: string; start: number; end: number }[] = []
for (const node of todo.nodes) {
const start = node.start
const end = node.start + node.content.length
if (node.type === "date") {
decorations.push({ type: "date", start, end })
} else if (node.type === "tag") {
decorations.push({ type: "tag", start, end })
}
}
return decorations
}

View File

@ -1,6 +1,6 @@
import { indentMore, indentLess } from "@codemirror/commands"
import { EditorView, keymap } from "@codemirror/view"
import { parseTodoLine, todoToText } from "./todo"
import { Todo } from "./todo"
import { type RefObject } from "hono/jsx"
export const todoKeymap = (filterElRef: RefObject<HTMLInputElement>) => {
@ -21,16 +21,15 @@ export const todoKeymap = (filterElRef: RefObject<HTMLInputElement>) => {
const { head } = state.selection.main
const line = state.doc.lineAt(head)
const { todo } = parseTodoLine(line.text)
const todo = Todo.parse(line.text)
if (!todo) return false
todo.done = !todo.done
const updatedLine = todoToText(todo)
const from = line.from
const to = line.to
const offset = head - from
view.dispatch({
changes: { from, to, insert: updatedLine },
changes: { from, to, insert: todo.toString() },
selection: { anchor: from + offset },
userEvent: "input",
})