Yeah, this works

This commit is contained in:
Corey Johnson 2025-06-27 13:27:20 -07:00
parent 24c5ac6dc5
commit 6ee2a00f07
7 changed files with 235 additions and 11 deletions

0
notification-test.html Normal file
View File

View File

@ -140,7 +140,11 @@ const migrateFromOldFormat = async (): Promise<void> => {
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 }

View File

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

View File

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

View File

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

View File

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

View File

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