Edit series fields inline from the sidebar
The sidebar was read-only — changing interval, due date, or the note
required a separate workflow. An edit/done toggle now exposes inline
controls, backed by a new PATCH /series/:id endpoint. The series note
uses a full OverType editor instance (with the same compact min-height
hack as history notes) so it can be edited in place.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index b3e4549..a2e1648 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -51,26 +51,63 @@ module Views
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-on:click": "$store.sidebar.toggleForm()",
- "x-bind:class": "$store.sidebar.mode === 'form' && 'sort-active'"
- ) { "+ New" }
+ "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" }
end
end
div(class: "task-detail", "x-show": "$store.sidebar.mode === 'task'") do
- div(id: "series-note-preview", class: "task-detail-note")
+ div(id: "series-note-detail", class: "task-detail-note")
dl(class: "task-detail-fields") do
dt { "Interval" }
- dd("x-text": "$store.sidebar.taskInterval")
+ 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-text": "$store.sidebar.taskDueDate")
+ 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
- dt("x-show": "$store.sidebar.taskUrgency !== ''") { "Urgency" }
- dd("x-show": "$store.sidebar.taskUrgency !== ''", "x-text": "$store.sidebar.taskUrgency")
+ dt("x-show": "$store.sidebar.taskUrgency !== '' && !$store.sidebar.editing") { "Urgency" }
+ dd("x-show": "$store.sidebar.taskUrgency !== '' && !$store.sidebar.editing", "x-text": "$store.sidebar.taskUrgency")
end
template("x-if": "$store.sidebar.completedTasks.length > 0") do
@@ -228,6 +265,8 @@ module Views
"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
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 8f32aee..080b5bf 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -64,6 +64,46 @@ class Web < Roda
series = Series.where(id: series_id, user_id: current_user.id).first
r.halt 404 unless series
+ r.patch do
+ updates = {}
+
+ if r.params.key?("note")
+ note = r.params["note"].to_s.strip
+ r.halt 422 if note.empty?
+ updates[:note] = note
+ end
+
+ if r.params.key?("interval_count") || r.params.key?("interval_unit")
+ interval_count = r.params.key?("interval_count") ? r.params["interval_count"].to_i : series.interval_count
+ interval_unit = r.params.key?("interval_unit") ? r.params["interval_unit"].to_s : series.interval_unit
+ r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
+ r.halt 422 unless interval_count >= 1
+ updates[:interval_count] = interval_count
+ updates[:interval_unit] = interval_unit
+ end
+
+ if r.params.key?("due_date")
+ begin
+ due_date = Date.parse(r.params["due_date"].to_s)
+ rescue Date::Error
+ r.halt 422
+ end
+ end
+
+ DB.transaction do
+ series.update(updates) unless updates.empty?
+ if due_date
+ active = series.active_task
+ active.update(due_date: due_date) if active
+ end
+ end
+
+ response["content-type"] = "application/json"
+ result = updates.transform_keys(&:to_s)
+ result["due_date"] = due_date.to_s if due_date
+ result.to_json
+ end
+
r.get "completed" do
completed = series.tasks_dataset
.exclude(completed_at: nil)
diff --git a/public/css/app.css b/public/css/app.css
index bbcc947..fc8759c 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -112,6 +112,12 @@ h2 { font-size: var(--step-0); font-weight: 600; }
content: "\200b";
}
+.aside-heading-action {
+ font-size: var(--step--1);
+ font-weight: 400;
+ cursor: default;
+}
+
.column-aside form {
font-size: var(--step--1);
}
@@ -137,7 +143,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
.sort-toggle button {
all: unset;
color: #999;
- cursor: pointer;
+ cursor: default;
}
.sort-toggle button:hover {
@@ -147,7 +153,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
.sort-toggle button.sort-active {
color: #1a1a1a;
font-weight: 600;
- cursor: default;
}
.sort-active {
@@ -211,8 +216,20 @@ h2 { font-size: var(--step-0); font-weight: 600; }
.task-detail-note {
color: #444;
margin-block-end: var(--space-s);
+ line-height: 1.3;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.task-detail-note:hover {
+ border-color: #ddd;
+}
+
+.task-detail-note:focus-within {
+ border-color: #999;
}
+
.task-detail-fields {
display: grid;
grid-template-columns: auto 1fr;
@@ -229,6 +246,51 @@ h2 { font-size: var(--step-0); font-weight: 600; }
color: #444;
}
+.detail-input {
+ font-size: var(--step--2);
+ padding: 1px 4px;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ color: #444;
+ font-family: inherit;
+}
+
+.detail-input:hover {
+ border-color: #ddd;
+}
+
+.detail-input:focus {
+ border-color: #999;
+ outline: none;
+ background: #fff;
+}
+
+.detail-edit-interval {
+ display: flex;
+ gap: 4px;
+}
+
+.detail-input-count {
+ width: 3.5em;
+ -moz-appearance: textfield;
+}
+
+.detail-input-count::-webkit-inner-spin-button,
+.detail-input-count::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.detail-input-unit {
+ flex: 1;
+ min-width: 0;
+}
+
+.detail-input-date {
+ width: 100%;
+}
+
.task-history {
margin-block-start: var(--space-s);
}
diff --git a/public/js/app.js b/public/js/app.js
index 4518a69..04f0780 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -29,14 +29,20 @@ 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,
init() {
const seriesId = sessionStorage.getItem("showSeries")
@@ -49,21 +55,21 @@ document.addEventListener("alpine:init", () => {
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.completedTasks = []
this.addingNoteId = null
+ this.editing = false
this.mode = "task"
- const noteEl = document.getElementById("series-note-preview")
- if (noteEl) {
- noteEl.innerHTML = ""
- noteEl.innerHTML = OverType.MarkdownParser.parse(this.taskNote)
- }
+ requestAnimationFrame(() => this._initSeriesNote())
fetch(`/series/${el.dataset.seriesId}/completed`)
.then((r) => r.json())
@@ -84,6 +90,28 @@ document.addEventListener("alpine:init", () => {
}
},
+ 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)
+ },
+
+ 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)
+ },
+
initNoteEditor(el, taskId, initialNote) {
if (!el) return
const [editor] = new OverType(el, {
@@ -134,6 +162,95 @@ 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",
+ })
+ this._seriesNoteEditor = editor
+
+ const wrapper = el.querySelector(".overtype-wrapper")
+ const ta = el.querySelector("textarea")
+ const preview = el.querySelector(".overtype-preview")
+ if (wrapper && ta) {
+ wrapper.style.setProperty("min-height", "0", "important")
+ ta.style.setProperty("padding", "0 4px", "important")
+ ta.style.setProperty("border", "none", "important")
+ // Store resize so startEditing/stopEditing can re-measure after
+ // toggling pointer-events and readOnly, which can shift the textarea.
+ this._resizeSeriesNote = () => {
+ ta.style.setProperty("height", "0", "important")
+ const h = ta.scrollHeight + "px"
+ ta.style.setProperty("height", h, "important")
+ wrapper.style.setProperty("height", h, "important")
+ if (preview) preview.style.setProperty("height", h, "important")
+ }
+ requestAnimationFrame(this._resizeSeriesNote)
+ ta.addEventListener("input", () => requestAnimationFrame(this._resizeSeriesNote))
+
+ 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)}`,
+ })
+ },
+
+ 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
+ }
+ })
+ },
+
completeTask(taskId, seriesId) {
fetch(`/tasks/${taskId}/complete`, { method: "POST" })
.then((r) => {
diff --git a/test/test_web.rb b/test/test_web.rb
index bc3fb54..deda2d8 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -282,6 +282,108 @@ class TestWeb < Minitest::Test
assert_equal 404, last_response.status
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
+
+ series = DB[:series].first
+ patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ assert_equal "Call Dad", body["note"]
+ assert_equal "Call Dad", DB[:series].first(id: series[:id])[:note]
+ 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
+
+ series = DB[:series].first
+ patch "/series/#{series[:id]}", { interval_count: "3", interval_unit: "month" }, tailscale_headers
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ assert_equal 3, body["interval_count"]
+ assert_equal "month", body["interval_unit"]
+
+ updated = DB[:series].first(id: series[:id])
+ assert_equal 3, updated[:interval_count]
+ assert_equal "month", updated[:interval_unit]
+ 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
+
+ series = DB[:series].first
+ task = DB[:tasks].first(series_id: series[:id])
+ patch "/series/#{series[:id]}", { due_date: "2026-04-15" }, tailscale_headers
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ assert_equal "2026-04-15", body["due_date"]
+ assert_equal Date.new(2026, 4, 15), DB[:tasks].first(id: task[:id])[:due_date]
+ 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
+
+ series = DB[:series].first
+ patch "/series/#{series[:id]}", { interval_unit: "fortnight" }, tailscale_headers
+ assert_equal 422, last_response.status
+ 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
+
+ series = DB[:series].first
+ patch "/series/#{series[:id]}", { interval_count: "0" }, tailscale_headers
+ assert_equal 422, last_response.status
+ end
+
+ def test_patch_series_requires_own_series
+ post "/series", {
+ note: "Alice task", interval_unit: "day", interval_count: "1",
+ first_due_date: "2026-03-01"
+ }, tailscale_headers(login: "alice@example.com", name: "Alice")
+
+ series = DB[:series].first
+ patch "/series/#{series[:id]}", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
+ assert_equal 404, last_response.status
+ 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
+
+ series = DB[:series].first
+ patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
+ assert last_response.ok?
+
+ updated = DB[:series].first(id: series[:id])
+ assert_equal "Call Dad", updated[:note]
+ assert_equal "week", updated[:interval_unit]
+ assert_equal 2, updated[:interval_count]
+
+ task = DB[:tasks].first(series_id: series[:id])
+ assert_equal Date.new(2026, 3, 1), task[:due_date]
+ end
+
def test_completed_includes_notes
post "/series", {
note: "Call Mom", interval_unit: "week", interval_count: "1",