Yeah, this works
This commit is contained in:
parent
24c5ac6dc5
commit
6ee2a00f07
0
notification-test.html
Normal file
0
notification-test.html
Normal 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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
195
packages/todo/src/todoTimer.ts
Normal file
195
packages/todo/src/todoTimer.ts
Normal 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 }
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user