diff --git a/notification-test.html b/notification-test.html
new file mode 100644
index 0000000..e69de29
diff --git a/packages/shared/src/kv.ts b/packages/shared/src/kv.ts
index 79a6eeb..f516b82 100644
--- a/packages/shared/src/kv.ts
+++ b/packages/shared/src/kv.ts
@@ -140,7 +140,11 @@ const migrateFromOldFormat = async (): Promise => {
console.error("Error during migration:", error)
}
}
-await migrateFromOldFormat()
+
+if (typeof process !== "undefined") {
+ // If this is running via bun, run the migration
+ await migrateFromOldFormat()
+}
export default { set, get, remove, update }
diff --git a/packages/todo/src/index.css b/packages/todo/src/index.css
index 24eb267..bfc24b1 100644
--- a/packages/todo/src/index.css
+++ b/packages/todo/src/index.css
@@ -1,17 +1,13 @@
/* Todo styling */
-.todo-completed {
+.todo-completed * {
text-decoration: line-through;
- color: #6B7280;
+ color: #D1D5DB !important;
}
.todo-tag {
color: #3B82F6;
}
-.todo-completed .todo-tag, .todo-completed .todo-date, .todo-completed .todo-time-estimate {
- color: #6B7280;
-}
-
.todo-date {
color: #10B981;
}
@@ -57,4 +53,31 @@
.cm-scroller {
flex: 1 1 auto;
height: 100%;
+}
+
+/* Timer widget styles */
+.timer-running .cm-line {
+ opacity : 0.3;
+}
+
+.timer-running .cm-line.neat {
+ opacity : 1;
+}
+
+.countdown-timer {
+ margin-left: 12px;
+ color: black;
+ padding: 2px 5px;
+ letter-spacing: 1px;
+ cursor: pointer;
+ user-select: none;
+ pointer-events: auto;
+ outline: none;
+}
+
+.countdown-timer.running {
+ margin-left: 12px;
+ color: black;
+ padding: 2px 5px;
+ letter-spacing: 1px;
}
\ No newline at end of file
diff --git a/packages/todo/src/todo.ts b/packages/todo/src/todo.ts
index 15a26ee..7a58030 100644
--- a/packages/todo/src/todo.ts
+++ b/packages/todo/src/todo.ts
@@ -101,7 +101,7 @@ const parseTodo = (line: string): Todo | undefined => {
// eat text
const isTag = line.slice(offset).startsWith("#")
const isDate = line.slice(offset).startsWith("@")
- const isTimeEstimate = line.slice(offset).match(/^[\d\.]+[mh]$/)
+ const isTimeEstimate = line.slice(offset).match(/^[\d\.]+\w($|\s+)/)
let node: TodoNode | undefined
if (isTag) {
@@ -124,13 +124,13 @@ const parseTodo = (line: string): Todo | undefined => {
}
const parseTimeEstimate = (line: string, start: number) => {
- const timeMatch = line.slice(start).match(/^([\d\.]+)([mh])($|\s+)/)
+ const timeMatch = line.slice(start).match(/^([\d\.]+)([mhs])($|\s+)/)
const [_, numberString, unitString] = timeMatch ?? []
if (!numberString || !unitString) {
return parseText(line, start)
}
- const value = parseInt(numberString, 10)
+ const value = parseFloat(numberString)
const unit = unitString.toLowerCase()
let seconds: number
diff --git a/packages/todo/src/todoDecorations.ts b/packages/todo/src/todoDecorations.ts
index 4166b96..85e7388 100644
--- a/packages/todo/src/todoDecorations.ts
+++ b/packages/todo/src/todoDecorations.ts
@@ -1,6 +1,6 @@
import { Todo } from "@/todo"
import { RangeSetBuilder, StateEffect } from "@codemirror/state"
-import { EditorView, Decoration, ViewPlugin, ViewUpdate } from "@codemirror/view"
+import { EditorView, Decoration, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view"
import { type RefObject } from "hono/jsx"
import { DateTime } from "luxon"
diff --git a/packages/todo/src/todoEditor.tsx b/packages/todo/src/todoEditor.tsx
index 2a93925..bd3ac94 100644
--- a/packages/todo/src/todoEditor.tsx
+++ b/packages/todo/src/todoEditor.tsx
@@ -9,6 +9,7 @@ import { autoTodoOnNewline } from "@/autoTodoOnNewline"
import { todoKeymap } from "@/todoKeymap"
import { todoClickHandler } from "@/todoClickHandler"
import { dateAutocompletion } from "@/dateAutocompletion"
+import { todoTimer } from "@/todoTimer"
import { DateTime } from "luxon"
import "./index.css"
@@ -53,6 +54,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
todoKeymap(filterElRef),
todoClickHandler,
dateAutocompletion,
+ todoTimer,
keymap.of(historyKeymap),
keymap.of(defaultKeymap),
viewKeymap.of(foldKeymap),
diff --git a/packages/todo/src/todoTimer.ts b/packages/todo/src/todoTimer.ts
new file mode 100644
index 0000000..b7d52cc
--- /dev/null
+++ b/packages/todo/src/todoTimer.ts
@@ -0,0 +1,195 @@
+import { EditorView, ViewPlugin, ViewUpdate, WidgetType, Decoration } from "@codemirror/view"
+import { RangeSetBuilder } from "@codemirror/state"
+import { Todo } from "@/todo"
+
+class CountdownWidget extends WidgetType {
+ duration: number
+ startedAt?: number
+ intervalId?: number
+ view: EditorView
+
+ constructor(duration: number, view: EditorView) {
+ super()
+ this.duration = duration * 1000
+ this.view = view
+ }
+
+ startTimer() {
+ if (this.startedAt) return
+ this.view.dom.classList.add("timer-running")
+ this.startedAt = Date.now()
+
+ if (Notification.permission === "default") {
+ Notification.requestPermission()
+ }
+ }
+
+ 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()
+ }
+ })
+
+ const updateTimer = () => {
+ if (this.startedAt) {
+ span.classList.add("running")
+ } else {
+ span.classList.remove("running")
+ }
+
+ if (!this.startedAt) {
+ span.textContent = "▶️"
+ return
+ }
+
+ const elapsed = Date.now() - this.startedAt
+ const remaining = this.duration - elapsed
+
+ if (remaining <= 0) {
+ span.textContent = "⏰ TIME'S UP!"
+
+ this.stopTimer()
+ }
+
+ const seconds = Math.ceil(remaining / 1000)
+ span.textContent = `${seconds.toString().padStart(2, "0")}s`
+ }
+
+ updateTimer()
+ this.intervalId = setInterval(updateTimer, 100) as any
+
+ 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...")
+ await Notification.requestPermission()
+ }
+
+ if (Notification.permission === "granted") {
+ new Notification("⏰ Timer Finished!", {
+ body: "Your countdown timer has reached zero.",
+ icon: "⏰",
+ requireInteraction: true,
+ tag: "countdown-timer",
+ })
+ } else {
+ console.log("Notification permission denied or blocked")
+ }
+ }
+
+ 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)
+ this.intervalId = undefined
+ }
+ }
+}
+
+export const todoTimer = ViewPlugin.fromClass(
+ class {
+ decorations: any
+ currentWidget?: CountdownWidget
+
+ 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) {
+ if (!update.selectionSet) return
+ if (this.currentWidget?.startedAt) return
+
+ this.decorations = this.buildDecorations(update.view)
+ }
+
+ buildDecorations(view: EditorView) {
+ const builder = new RangeSetBuilder()
+ 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) {
+ return builder.finish()
+ }
+
+ this.currentWidget = new CountdownWidget(todo.timeEstimate, view)
+ const decoration = Decoration.widget({ widget: this.currentWidget, side: 1 })
+
+ builder.add(line.to, line.to, decoration)
+ return builder.finish()
+ }
+ },
+ { decorations: (v) => v.decorations }
+)