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
change wzszkvruuxnonqutnswmsmqkxlzkloqq
commit 94a9f54e464450e9049015bb3181f647b8c5ea25
author Alpha Chen <alpha@kejadlen.dev>
date
parent pmryqtot
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",