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