Replace data-attribute sidebar store with URL-based navigation
GET /series/:id renders the home page with sidebar populated from
server data, so task cards become links and the Alpine store gives
way to local x-data components.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index a2e1648..3bb60d3 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -6,10 +6,14 @@ require_relative "layout"
module Views
class Home < Phlex::HTML
- def initialize(current_user:, overdue:, upcoming:)
+ def initialize(current_user:, overdue:, upcoming:,
+ selected_series: nil, selected_task: nil, completed_tasks: [])
@current_user = current_user
@overdue = overdue
@upcoming = upcoming
+ @selected_series = selected_series
+ @selected_task = selected_task
+ @completed_tasks = completed_tasks
end
def view_template
@@ -47,138 +51,182 @@ module Views
upcoming_list(@upcoming)
end
- div(class: "column column-aside", "x-data": "") do
- div(class: "column-header") do
- h2(class: "aside-heading") do
- span("x-show": "$store.sidebar.mode === 'form'") { "New Series" }
- span(
- "x-show": "$store.sidebar.mode !== 'form'",
- class: "aside-heading-action",
- "x-on:click": "$store.sidebar.toggleForm()"
- ) { "+ New" }
- end
- nav(class: "sort-toggle") do
- button(
- "x-show": "$store.sidebar.mode === 'task' && !$store.sidebar.editing",
- "x-on:click": "$store.sidebar.startEditing()"
- ) { "Edit" }
- button(
- "x-show": "$store.sidebar.mode === 'task' && $store.sidebar.editing",
- "x-on:click": "$store.sidebar.stopEditing()"
- ) { "Done" }
+ if @selected_series
+ series_detail_sidebar
+ else
+ new_series_sidebar
+ end
+ end
+ end
+ end
+
+ private
+
+ def series_detail_sidebar
+ div(class: "column column-aside", "x-data": "{ editing: false }") do
+ div(class: "column-header") do
+ h2(class: "aside-heading") do
+ a(href: "/", class: "aside-heading-action") { "+ New" }
+ end
+ nav(class: "sort-toggle") do
+ button(
+ "x-show": "!editing",
+ "x-on:click": "editing = true; $dispatch('start-editing')"
+ ) { "Edit" }
+ button(
+ "x-show": "editing",
+ "x-on:click": "editing = false; $dispatch('stop-editing')"
+ ) { "Done" }
+ end
+ end
+
+ div(class: "task-detail") do
+ div(
+ id: "series-note-detail",
+ class: "task-detail-note",
+ "data-value": @selected_series.note || "",
+ "data-series-id": @selected_series.id.to_s
+ )
+ dl(class: "task-detail-fields") do
+ dt { "Interval" }
+ dd("x-show": "!editing") do
+ plain interval_text(@selected_series.interval_count, @selected_series.interval_unit)
+ end
+ dd(
+ class: "detail-edit-interval",
+ "x-show": "editing",
+ "x-cloak": true,
+ "x-data": "intervalEditor(#{@selected_series.id}, #{@selected_series.interval_count}, '#{@selected_series.interval_unit}')",
+ ) do
+ input(
+ type: "number",
+ class: "detail-input detail-input-count",
+ min: 1,
+ "x-model.number": "count",
+ "x-on:change": "save()"
+ )
+ select(
+ class: "detail-input detail-input-unit",
+ "x-model": "unit",
+ "x-on:change": "save()"
+ ) do
+ option(value: "day") { "day(s)" }
+ option(value: "week") { "week(s)" }
+ option(value: "month") { "month(s)" }
+ option(value: "quarter") { "quarter(s)" }
+ option(value: "year") { "year(s)" }
end
end
- div(class: "task-detail", "x-show": "$store.sidebar.mode === 'task'") do
- div(id: "series-note-detail", class: "task-detail-note")
- dl(class: "task-detail-fields") do
- dt { "Interval" }
- dd("x-show": "!$store.sidebar.editing", "x-text": "$store.sidebar.taskInterval")
- dd(class: "detail-edit-interval", "x-show": "$store.sidebar.editing", "x-cloak": true) do
- input(
- type: "number",
- class: "detail-input detail-input-count",
- min: 1,
- "x-model.number": "$store.sidebar.intervalCount",
- "x-on:change": "$store.sidebar.saveInterval()"
- )
- select(
- class: "detail-input detail-input-unit",
- "x-model": "$store.sidebar.intervalUnit",
- "x-on:change": "$store.sidebar.saveInterval()"
- ) do
- option(value: "day") { "day(s)" }
- option(value: "week") { "week(s)" }
- option(value: "month") { "month(s)" }
- option(value: "quarter") { "quarter(s)" }
- option(value: "year") { "year(s)" }
- end
- end
-
- dt { "Due date" }
- dd("x-show": "!$store.sidebar.editing", "x-text": "$store.sidebar.taskDueDate")
- dd("x-show": "$store.sidebar.editing", "x-cloak": true) do
- input(
- type: "date",
- class: "detail-input detail-input-date",
- "x-model": "$store.sidebar.taskDueDate",
- "x-on:change": "$store.sidebar.saveSeriesField('due_date', $store.sidebar.taskDueDate)"
- )
- end
+ if @selected_task
+ dt { "Due date" }
+ dd("x-show": "!editing") { @selected_task[:due_date].to_s }
+ dd(
+ "x-show": "editing",
+ "x-cloak": true,
+ "x-data": "dueDateEditor(#{@selected_series.id}, '#{@selected_task[:due_date]}')"
+ ) do
+ input(
+ type: "date",
+ class: "detail-input detail-input-date",
+ "x-model": "dueDate",
+ "x-on:change": "save()"
+ )
+ end
- dt("x-show": "$store.sidebar.taskUrgency !== '' && !$store.sidebar.editing") { "Urgency" }
- dd("x-show": "$store.sidebar.taskUrgency !== '' && !$store.sidebar.editing", "x-text": "$store.sidebar.taskUrgency")
+ if @selected_task.urgency > 0
+ dt("x-show": "!editing") { "Urgency" }
+ dd("x-show": "!editing") { "#{format("%.1f", @selected_task.urgency)}x" }
end
+ end
+ end
- template("x-if": "$store.sidebar.completedTasks.length > 0") do
- div(class: "task-history") do
- h3 { "History" }
- ul do
- template("x-for": "ct in $store.sidebar.completedTasks") do
- li(class: "task-history-item") do
- div(class: "task-history-row") do
- span(class: "task-history-check") { "✓" }
- span(class: "task-history-date", "x-text": "ct.completed_at")
- template("x-if": "!ct.note && $store.sidebar.addingNoteId !== ct.id") do
- span(
- class: "task-history-add-note",
- "x-on:click": "$store.sidebar.addingNoteId = ct.id"
- ) { "add a note..." }
- end
- end
- template("x-if": "ct.note || $store.sidebar.addingNoteId === ct.id") do
- div(
- class: "task-history-note-editor",
- "x-init": "$store.sidebar.initNoteEditor($el, ct.id, ct.note || '')"
- )
- end
+ unless @completed_tasks.empty?
+ div(class: "task-history") do
+ h3 { "History" }
+ ul do
+ @completed_tasks.each do |ct|
+ li(class: "task-history-item") do
+ div(class: "task-history-row") do
+ span(class: "task-history-check") { "✓" }
+ span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
+ if ct[:note].nil?
+ span(
+ class: "task-history-add-note",
+ "x-data": "{ adding: false }",
+ "x-show": "!adding",
+ "x-on:click": "adding = true; $dispatch('add-note-#{ct[:id]}')"
+ ) { "add a note..." }
end
end
+ div(
+ class: "task-history-note-editor",
+ "data-task-id": ct[:id].to_s,
+ "data-value": ct[:note] || "",
+ "x-data": "historyNoteEditor",
+ "x-init": "init()"
+ ) if ct[:note]
+ div(
+ class: "task-history-note-editor",
+ "data-task-id": ct[:id].to_s,
+ "data-value": "",
+ "x-data": "historyNoteEditor",
+ style: "display: none",
+ "x-on:add-note-#{ct[:id]}.window": "show($el); init()"
+ ) if ct[:note].nil?
end
end
end
end
+ end
+ end
+ end
+ end
- form(method: "post", action: "/series", "x-show": "$store.sidebar.mode === 'form'") do
- div(class: "field") do
- label(for: "note") { "Note" }
- div(id: "series-note-editor")
- end
+ def new_series_sidebar
+ div(class: "column column-aside") do
+ div(class: "column-header") do
+ h2(class: "aside-heading") do
+ span { "New Series" }
+ end
+ end
- div(class: "field") do
- label(for: "interval_count") { "Repeat every" }
- div(class: "interval") do
- input(
- type: "number", id: "interval_count", name: "interval_count",
- min: 1, value: 1, required: true
- )
- select(id: "interval_unit", name: "interval_unit", required: true) do
- option(value: "day") { "day(s)" }
- option(value: "week") { "week(s)" }
- option(value: "month") { "month(s)" }
- option(value: "quarter") { "quarter(s)" }
- option(value: "year") { "year(s)" }
- end
- end
- end
+ form(method: "post", action: "/series") do
+ div(class: "field") do
+ label(for: "note") { "Note" }
+ div(id: "series-note-editor")
+ end
- div(class: "field") do
- label(for: "first_due_date") { "First due date" }
- input(
- type: "date", id: "first_due_date", name: "first_due_date",
- value: Date.today.to_s, required: true
- )
+ div(class: "field") do
+ label(for: "interval_count") { "Repeat every" }
+ div(class: "interval") do
+ input(
+ type: "number", id: "interval_count", name: "interval_count",
+ min: 1, value: 1, required: true
+ )
+ select(id: "interval_unit", name: "interval_unit", required: true) do
+ option(value: "day") { "day(s)" }
+ option(value: "week") { "week(s)" }
+ option(value: "month") { "month(s)" }
+ option(value: "quarter") { "quarter(s)" }
+ option(value: "year") { "year(s)" }
end
-
- button(type: "submit") { "Create" }
end
end
+
+ div(class: "field") do
+ label(for: "first_due_date") { "First due date" }
+ input(
+ type: "date", id: "first_due_date", name: "first_due_date",
+ value: Date.today.to_s, required: true
+ )
+ end
+
+ button(type: "submit") { "Create" }
end
end
end
- private
-
def task_list(tasks, empty:, sortable: false)
if tasks.empty?
p(class: "empty") { empty }
@@ -255,29 +303,17 @@ module Views
def task_card(task, sortable: false)
name = task[:note].lines.first&.strip || task[:note]
overdue = task[:due_date] < Date.today
+ selected = @selected_series && @selected_series.id == task[:series_id]
- div(
- class: ["task-card", ("task-overdue" if overdue)],
- "x-on:click": "$store.sidebar.showTask($el)",
- "x-bind:class": "$store.sidebar.taskId == '#{task[:id]}' && 'task-selected'",
- "data-task-id": task[:id].to_s,
- "data-series-id": task[:series_id].to_s,
- "data-task-name": name,
- "data-task-note": task[:note],
- "data-task-interval": interval_text(task[:interval_count], task[:interval_unit]),
- "data-interval-count": task[:interval_count].to_s,
- "data-interval-unit": task[:interval_unit],
- "data-task-due-date": task[:due_date].to_s,
- "data-task-urgency": task.urgency > 0 ? "#{format("%.1f", task.urgency)}x" : "",
- "data-task-overdue": overdue.to_s
- ) do
- button(
- type: "button", title: "Complete",
- class: "complete-btn",
- "x-on:click.stop": "$store.sidebar.completeTask(#{task[:id]}, #{task[:series_id]})",
- **{ "aria-label": "Complete #{name}" }
- ) { "✓" }
- span(class: "task-name") { name }
+ div(class: ["task-card", ("task-overdue" if overdue), ("task-selected" if selected)]) do
+ form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
+ button(
+ type: "submit", title: "Complete",
+ class: "complete-btn",
+ **{ "aria-label": "Complete #{name}" }
+ ) { "✓" }
+ end
+ a(href: "/series/#{task[:series_id]}", class: "task-name") { name }
if sortable && overdue
span(class: "task-secondary task-urgency", "x-show": "sort === 'urgency'") { "#{format("%.1f", task.urgency)}x" } if task.urgency > 0
span(class: "task-secondary", "x-show": "sort === 'date'") { task[:due_date].to_s }
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 080b5bf..f9b770f 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -19,16 +19,27 @@ class Web < Roda
User.find_or_create(login: login) { |u| u.name = name }
end
+ def active_tasks_for(user)
+ all_tasks = Task.active.for_user(user).all
+ overdue, upcoming = all_tasks.partition { |t| t.urgency > 0 }
+ overdue.sort_by! { |t| -t.urgency }
+ upcoming.sort_by! { |t| t[:due_date] }
+ [overdue, upcoming]
+ end
+
+ def completed_tasks_for(series)
+ series.tasks_dataset
+ .exclude(completed_at: nil)
+ .order(Sequel.desc(:completed_at))
+ .select(:id, :due_date, :completed_at, :note)
+ .all
+ end
+
route do |r|
r.halt 403 unless current_user
r.root do
- all_tasks = Task.active.for_user(current_user).all
- overdue, upcoming = all_tasks.partition { |t| t.urgency > 0 }
-
- overdue.sort_by! { |t| -t.urgency }
- upcoming.sort_by! { |t| t[:due_date] }
-
+ overdue, upcoming = active_tasks_for(current_user)
Views::Home.new(current_user:, overdue:, upcoming:).call
end
@@ -38,9 +49,7 @@ class Web < Roda
r.halt 404 unless task
task.complete!
- new_task = task.series.active_task
- response["content-type"] = "application/json"
- { series_id: task[:series_id], task: { id: new_task.id, due_date: new_task.due_date.to_s } }.to_json
+ r.redirect "/series/#{task[:series_id]}"
end
r.patch "note" do
@@ -64,6 +73,17 @@ class Web < Roda
series = Series.where(id: series_id, user_id: current_user.id).first
r.halt 404 unless series
+ r.get true do
+ overdue, upcoming = active_tasks_for(current_user)
+
+ Views::Home.new(
+ current_user:, overdue:, upcoming:,
+ selected_series: series,
+ selected_task: series.active_task,
+ completed_tasks: completed_tasks_for(series)
+ ).call
+ end
+
r.patch do
updates = {}
@@ -104,17 +124,6 @@ class Web < Roda
result.to_json
end
- r.get "completed" do
- completed = series.tasks_dataset
- .exclude(completed_at: nil)
- .order(Sequel.desc(:completed_at))
- .select(:id, :due_date, :completed_at, :note)
- .all
- .map { |t| { id: t[:id], due_date: t[:due_date].to_s, completed_at: t[:completed_at].strftime("%Y-%m-%d"), note: t[:note] } }
-
- response["content-type"] = "application/json"
- completed.to_json
- end
end
r.on "series" do
@@ -134,7 +143,7 @@ class Web < Roda
r.halt 422
end
- Series.create_with_first_task(
+ series = Series.create_with_first_task(
user: current_user,
note: note,
interval_unit: interval_unit,
@@ -142,7 +151,7 @@ class Web < Roda
first_due_date: due_date
)
- r.redirect "/"
+ r.redirect "/series/#{series.id}"
end
end
end
diff --git a/public/css/app.css b/public/css/app.css
index fc8759c..15c0c10 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -130,6 +130,15 @@ h2 { font-size: var(--step-0); font-weight: 600; }
font-size: var(--step--1);
}
+#series-note-editor {
+ border: 1px solid #ccc;
+ border-radius: 3px;
+}
+
+#series-note-editor:focus-within {
+ border-color: #999;
+}
+
/* -------------------- */
/* Task list */
/* -------------------- */
@@ -222,7 +231,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.task-detail-note:hover {
- border-color: #ddd;
+ border-color: transparent;
}
.task-detail-note:focus-within {
@@ -470,7 +479,7 @@ textarea {
gap: var(--space-3xs);
}
-button[type="submit"] {
+button[type="submit"]:not(.complete-btn) {
padding: var(--space-3xs) var(--space-s);
border: 1px solid #1a1a1a;
border-radius: 3px;
@@ -481,10 +490,14 @@ button[type="submit"] {
cursor: pointer;
}
-button[type="submit"]:hover {
+button[type="submit"]:not(.complete-btn):hover {
background: #333;
}
+.complete-form {
+ display: contents;
+}
+
.complete-btn {
width: 14px;
height: 14px;
diff --git a/public/js/app.js b/public/js/app.js
index a3f9efd..79bdc9f 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -48,6 +48,14 @@ function compactOverType(el) {
return resize
}
+function saveSeriesField(seriesId, field, value) {
+ return fetch(`/series/${seriesId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: `${encodeURIComponent(field)}=${encodeURIComponent(value)}`,
+ })
+}
+
document.addEventListener("alpine:init", () => {
Alpine.data("sortable", () => ({
sort: localStorage.getItem("sort") || "urgency",
@@ -76,97 +84,41 @@ document.addEventListener("alpine:init", () => {
},
}))
- Alpine.store("sidebar", {
- mode: "",
- taskId: null,
- seriesId: null,
- taskName: "",
- taskNote: "",
- taskInterval: "",
- intervalCount: 1,
- intervalUnit: "day",
- taskDueDate: "",
- taskUrgency: "",
- taskOverdue: false,
- completedTasks: [],
- editing: false,
- addingNoteId: null,
- _seriesNoteEditor: null,
- _resizeSeriesNote: null,
- _noteEditors: [],
+ Alpine.data("intervalEditor", (seriesId, initialCount, initialUnit) => ({
+ count: initialCount,
+ unit: initialUnit,
- init() {
- const seriesId = sessionStorage.getItem("showSeries")
- if (seriesId) {
- sessionStorage.removeItem("showSeries")
- const el = document.querySelector(`[data-series-id="${seriesId}"]`)
- if (el) this.showTask(el)
- }
+ save() {
+ fetch(`/series/${seriesId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: `interval_count=${encodeURIComponent(this.count)}&interval_unit=${encodeURIComponent(this.unit)}`,
+ })
},
+ }))
- showTask(el) {
- this.taskId = el.dataset.taskId
- this.seriesId = el.dataset.seriesId
- this.taskName = el.dataset.taskName
- this.taskNote = el.dataset.taskNote
- this.taskInterval = el.dataset.taskInterval
- this.intervalCount = parseInt(el.dataset.intervalCount, 10) || 1
- this.intervalUnit = el.dataset.intervalUnit || "day"
- this.taskDueDate = el.dataset.taskDueDate
- this.taskUrgency = el.dataset.taskUrgency
- this.taskOverdue = el.dataset.taskOverdue === "true"
- this._noteEditors.forEach((e) => e.destroy())
- this._noteEditors = []
- this.completedTasks = []
- this.addingNoteId = null
- this.editing = false
- this.mode = "task"
-
- requestAnimationFrame(() => this._initSeriesNote())
+ Alpine.data("dueDateEditor", (seriesId, initialDate) => ({
+ dueDate: initialDate,
- fetch(`/series/${el.dataset.seriesId}/completed`)
- .then((r) => r.json())
- .then((data) => (this.completedTasks = data))
+ save() {
+ saveSeriesField(seriesId, "due_date", this.dueDate)
},
+ }))
- showForm() {
- this.mode = "form"
- this.taskId = null
- },
+ Alpine.data("historyNoteEditor", () => ({
+ _editor: null,
- toggleForm() {
- if (this.mode === "form") {
- this.mode = ""
- this.taskId = null
- } else {
- this.showForm()
- }
+ show(el) {
+ el.style.display = ""
},
- startEditing() {
- this.editing = true
- const ta = document.querySelector("#series-note-detail textarea")
- if (ta) {
- ta.style.pointerEvents = ""
- ta.readOnly = false
- ta.focus()
- }
- if (this._resizeSeriesNote) requestAnimationFrame(this._resizeSeriesNote)
- },
+ init() {
+ const el = this.$el
+ const taskId = el.dataset.taskId
+ const initialNote = el.dataset.value || ""
- stopEditing() {
- this._saveSeriesNote()
- const ta = document.querySelector("#series-note-detail textarea")
- if (ta) {
- ta.style.pointerEvents = "none"
- ta.readOnly = true
- }
- this.editing = false
- if (this._resizeSeriesNote) requestAnimationFrame(this._resizeSeriesNote)
- },
+ if (this._editor) return
- initNoteEditor(el, taskId, initialNote) {
- if (!el) return
const [editor] = new OverType(el, {
value: initialNote,
placeholder: "Add a note...",
@@ -176,19 +128,17 @@ document.addEventListener("alpine:init", () => {
padding: "0 4px",
})
- this._noteEditors.push(editor)
+ this._editor = editor
compactOverType(el)
const textarea = el.querySelector("textarea")
if (textarea) {
- if (this.addingNoteId === taskId) textarea.focus()
+ if (!initialNote) textarea.focus()
textarea.addEventListener("blur", () => {
const note = editor.getValue().trim()
- const ct = this.completedTasks.find((t) => t.id === taskId)
- if (this.addingNoteId === taskId) this.addingNoteId = null
- if (!ct || (note || null) === (ct.note || null)) return
+ if (note === (initialNote || "").trim()) return
- ct.note = note || null
+ el.dataset.value = note
fetch(`/tasks/${taskId}/note`, {
method: "PATCH",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -197,98 +147,63 @@ document.addEventListener("alpine:init", () => {
})
}
},
+ }))
- _initSeriesNote() {
- const el = document.getElementById("series-note-detail")
- if (!el) return
-
- if (this._seriesNoteEditor) {
- this._seriesNoteEditor.destroy()
- this._seriesNoteEditor = null
- }
- el.innerHTML = ""
-
- const [editor] = new OverType(el, {
- value: this.taskNote || "",
- placeholder: "Series note...",
- autoResize: true,
- minHeight: 14,
- padding: "0 4px",
+ // Series note detail editor — initialized when a series is selected
+ const noteDetail = document.getElementById("series-note-detail")
+ if (noteDetail) {
+ const seriesId = noteDetail.dataset.seriesId
+ const initialNote = noteDetail.dataset.value || ""
+
+ const [editor] = new OverType(noteDetail, {
+ value: initialNote,
+ placeholder: "Series note...",
+ autoResize: true,
+ minHeight: 14,
+ padding: "0 4px",
+ })
+
+ const resizeNote = compactOverType(noteDetail)
+
+ const ta = noteDetail.querySelector("textarea")
+ if (ta) {
+ ta.style.pointerEvents = "none"
+ ta.readOnly = true
+
+ ta.addEventListener("blur", () => {
+ const note = editor.getValue().trim()
+ if (note === (initialNote || "").trim()) return
+ saveSeriesField(seriesId, "note", note)
})
- this._seriesNoteEditor = editor
-
- // Store resize so startEditing/stopEditing can re-measure after
- // toggling pointer-events and readOnly.
- this._resizeSeriesNote = compactOverType(el)
-
- const ta = el.querySelector("textarea")
- if (ta) {
- ta.style.pointerEvents = "none"
- ta.readOnly = true
- ta.addEventListener("blur", () => this._saveSeriesNote())
- }
- },
- _saveSeriesNote() {
- if (!this._seriesNoteEditor) return
- const note = this._seriesNoteEditor.getValue().trim()
- if (note === (this.taskNote || "").trim()) return
-
- this.taskNote = note
- this.saveSeriesField("note", note)
- const card = document.querySelector(`[data-series-id="${this.seriesId}"]`)
- if (card) {
- card.dataset.taskNote = note
- card.dataset.taskName = note.split("\n")[0]?.trim() || note
- const nameEl = card.querySelector(".task-name")
- if (nameEl) nameEl.textContent = card.dataset.taskName
- }
- },
-
- saveSeriesField(field, value) {
- if (!this.seriesId) return
- fetch(`/series/${this.seriesId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `${encodeURIComponent(field)}=${encodeURIComponent(value)}`,
+ document.addEventListener("start-editing", () => {
+ ta.style.pointerEvents = ""
+ ta.readOnly = false
+ ta.focus()
+ if (resizeNote) requestAnimationFrame(resizeNote)
})
- },
- saveInterval() {
- if (!this.seriesId) return
- const count = this.intervalCount
- const unit = this.intervalUnit
- fetch(`/series/${this.seriesId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `interval_count=${encodeURIComponent(count)}&interval_unit=${encodeURIComponent(unit)}`,
- }).then(() => {
- const label = `Every ${count} ${count === 1 ? unit : unit + "s"}`
- this.taskInterval = label
- const card = document.querySelector(`[data-series-id="${this.seriesId}"]`)
- if (card) {
- card.dataset.taskInterval = label
- card.dataset.intervalCount = count
- card.dataset.intervalUnit = unit
+ document.addEventListener("stop-editing", () => {
+ const note = editor.getValue().trim()
+ if (note !== (initialNote || "").trim()) {
+ saveSeriesField(seriesId, "note", note)
}
+ ta.style.pointerEvents = "none"
+ ta.readOnly = true
+ if (resizeNote) requestAnimationFrame(resizeNote)
})
- },
-
- completeTask(taskId, seriesId) {
- fetch(`/tasks/${taskId}/complete`, { method: "POST" })
- .then((r) => {
- if (!r.ok) throw new Error()
- sessionStorage.setItem("showSeries", seriesId)
- window.location.reload()
- })
- },
- })
+ }
+ }
- new OverType("#series-note-editor", {
- placeholder: "What needs doing...",
- textareaProps: { name: "note", required: true },
- autoResize: true,
- })
+ // New series form editor
+ const newNoteEl = document.getElementById("series-note-editor")
+ if (newNoteEl) {
+ new OverType("#series-note-editor", {
+ placeholder: "What needs doing...",
+ textareaProps: { name: "note", required: true },
+ autoResize: true,
+ })
+ }
Alpine.data("upcoming", () => ({
showEmpty: localStorage.getItem("upcoming-show-empty") !== "false",
diff --git a/test/test_web.rb b/test/test_web.rb
index deda2d8..0703079 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -21,11 +21,8 @@ class TestWeb < Minitest::Test
def test_root_shows_new_series_form
get "/", {}, tailscale_headers
assert last_response.ok?
- assert_includes last_response.body, 'method="post" action="/series"'
- assert_includes last_response.body, 'id="series-note-editor"'
- assert_includes last_response.body, 'name="interval_count"'
- assert_includes last_response.body, 'name="interval_unit"'
- assert_includes last_response.body, 'name="first_due_date"'
+ assert_includes last_response.body, "New Series"
+ assert_includes last_response.body, 'action="/series"'
end
def test_root_shows_empty_state
@@ -35,10 +32,7 @@ class TestWeb < Minitest::Test
end
def test_root_shows_active_tasks
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", first_due_date: "2026-03-01", interval_unit: "week", interval_count: "2")
get "/", {}, tailscale_headers
assert_includes last_response.body, "Call Mom"
@@ -88,6 +82,7 @@ class TestWeb < Minitest::Test
assert_equal "Call Mom", series[:note]
assert_equal "week", series[:interval_unit]
assert_equal 2, series[:interval_count]
+ assert_includes last_response["Location"], "/series/#{series[:id]}"
end
def test_create_series_creates_first_task
@@ -159,30 +154,26 @@ class TestWeb < Minitest::Test
end
def test_complete_task
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
task = DB[:tasks].first
post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
- assert last_response.ok?
+ assert last_response.redirect?
- body = JSON.parse(last_response.body)
- new_task = DB[:tasks].where(completed_at: nil).first
- assert_equal new_task[:id], body["task"]["id"]
- assert_equal (Date.today + 14).to_s, body["task"]["due_date"]
+ series = DB[:series].first
+ assert_includes last_response["Location"], "/series/#{series[:id]}"
old_task = DB[:tasks].first(id: task[:id])
refute_nil old_task[:completed_at]
+
+ new_task = DB[:tasks].where(completed_at: nil).first
assert_equal Date.today + 14, new_task[:due_date]
end
def test_complete_task_advances_by_months
- post "/series", {
- note: "Dentist", interval_unit: "month", interval_count: "3",
- first_due_date: "2026-01-31"
- }, tailscale_headers
+ create_series(note: "Dentist", interval_unit: "month", interval_count: "3",
+ first_due_date: "2026-01-31")
task = DB[:tasks].first
post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
@@ -203,10 +194,8 @@ class TestWeb < Minitest::Test
end
def test_complete_task_requires_tailscale_user
- post "/series", {
- note: "Call Mom", interval_unit: "day", interval_count: "1",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "day", interval_count: "1",
+ first_due_date: "2026-03-01")
task = DB[:tasks].first
post "/tasks/#{task[:id]}/complete"
@@ -221,29 +210,84 @@ class TestWeb < Minitest::Test
assert_equal 403, last_response.status
end
- def test_task_card_has_data_attributes
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ def test_task_card_links_to_series
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+ series = DB[:series].first
get "/", {}, tailscale_headers
- assert_includes last_response.body, 'data-task-name="Call Mom"'
- assert_includes last_response.body, 'data-task-note="Call Mom"'
- assert_includes last_response.body, 'data-task-interval="Every 2 weeks"'
- assert_includes last_response.body, 'data-task-due-date="2026-03-01"'
+ assert_includes last_response.body, "href=\"/series/#{series[:id]}\""
+ assert_includes last_response.body, "Call Mom"
end
- def test_sidebar_has_new_button
+ def test_task_card_has_complete_form
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+
+ task = DB[:tasks].first
get "/", {}, tailscale_headers
+ assert_includes last_response.body, "action=\"/tasks/#{task[:id]}/complete\""
+ end
+
+
+ def test_series_sidebar_has_new_link
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+
+ series = DB[:series].first
+ get "/series/#{series[:id]}", {}, tailscale_headers
assert_includes last_response.body, "+ New"
+ assert_includes last_response.body, 'href="/"'
end
- def test_patch_note_saves_on_completed_task
+ def test_get_series_shows_sidebar
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+
+ series = DB[:series].first
+ get "/series/#{series[:id]}", {}, tailscale_headers
+ assert last_response.ok?
+ assert_includes last_response.body, "Every 2 weeks"
+ assert_includes last_response.body, "2026-03-01"
+ assert_includes last_response.body, "task-selected"
+ end
+
+ def test_get_series_shows_completed_history
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: "2026-03-01")
+
+ task = DB[:tasks].first
+ post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch "/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
+
+ series = DB[:series].first
+ get "/series/#{series[:id]}", {}, tailscale_headers
+ assert last_response.ok?
+ assert_includes last_response.body, "Left a message"
+ assert_includes last_response.body, "task-history"
+ end
+
+ def test_get_series_requires_own_series
post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "1",
+ note: "Alice task", interval_unit: "day", interval_count: "1",
first_due_date: "2026-03-01"
- }, tailscale_headers
+ }, tailscale_headers(login: "alice@example.com", name: "Alice")
+
+ series = DB[:series].first
+ get "/series/#{series[:id]}", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
+ assert_equal 404, last_response.status
+ end
+
+ def test_get_series_404_for_nonexistent
+ get "/series/999999", {}, tailscale_headers
+ assert_equal 404, last_response.status
+ end
+
+ def test_patch_note_saves_on_completed_task
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: "2026-03-01")
task = DB[:tasks].first
post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
@@ -258,10 +302,8 @@ class TestWeb < Minitest::Test
end
def test_patch_note_rejects_active_task
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "1",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: "2026-03-01")
task = DB[:tasks].first
patch "/tasks/#{task[:id]}/note", { note: "nope" }, tailscale_headers
@@ -283,10 +325,8 @@ class TestWeb < Minitest::Test
end
def test_patch_series_updates_note
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
series = DB[:series].first
patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
@@ -298,10 +338,8 @@ class TestWeb < Minitest::Test
end
def test_patch_series_updates_interval
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
series = DB[:series].first
patch "/series/#{series[:id]}", { interval_count: "3", interval_unit: "month" }, tailscale_headers
@@ -317,10 +355,8 @@ class TestWeb < Minitest::Test
end
def test_patch_series_updates_due_date
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
series = DB[:series].first
task = DB[:tasks].first(series_id: series[:id])
@@ -333,10 +369,8 @@ class TestWeb < Minitest::Test
end
def test_patch_series_rejects_invalid_interval_unit
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
series = DB[:series].first
patch "/series/#{series[:id]}", { interval_unit: "fortnight" }, tailscale_headers
@@ -344,10 +378,8 @@ class TestWeb < Minitest::Test
end
def test_patch_series_rejects_zero_interval_count
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
series = DB[:series].first
patch "/series/#{series[:id]}", { interval_count: "0" }, tailscale_headers
@@ -366,10 +398,8 @@ class TestWeb < Minitest::Test
end
def test_patch_series_ignores_fields_not_provided
- post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01"
- }, tailscale_headers
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
series = DB[:series].first
patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
@@ -384,30 +414,15 @@ class TestWeb < Minitest::Test
assert_equal Date.new(2026, 3, 1), task[:due_date]
end
- def test_completed_includes_notes
+ private
+
+ def create_series(note:, interval_unit:, interval_count:, first_due_date:)
post "/series", {
- note: "Call Mom", interval_unit: "week", interval_count: "1",
- first_due_date: "2026-03-01"
+ note: note, interval_unit: interval_unit, interval_count: interval_count,
+ first_due_date: first_due_date
}, tailscale_headers
-
- task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
-
- completed_task = DB[:tasks].first(id: task[:id])
- patch "/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
-
- series = DB[:series].first
- get "/series/#{series[:id]}/completed", {}, tailscale_headers
- assert last_response.ok?
-
- data = JSON.parse(last_response.body)
- assert_equal 1, data.length
- assert_equal "Left a message", data[0]["note"]
- assert data[0].key?("id")
end
- private
-
def tailscale_headers(
login: "alice@example.com",
name: "Alice",