Centralize series edits behind one Save
A section-level seriesEditor batches interval, shared, and note changes into a single PATCH fired on Save click, replacing the prior auto-save on change/blur.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change nmzuoruopslswkxmruvkuzkporpvnkwn
commit d9104f5048d761ff5846cae9a70729c9d090ae09
author Alpha Chen <alpha@kejadlen.dev>
date
parent xouttxus
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index baa4c0f..50fca78 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -3,7 +3,6 @@
 require "phlex"
 
 require_relative "../layout"
-require_relative "../shared_icon"
 
 module Ketchup
   module Views
@@ -22,7 +21,10 @@ module Ketchup
           render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
             div(class: "dashboard") do
               div(class: "main-column") do
-                section(class: "section", "x-data": "{ editing: false }") do
+                section(
+                  class: "section",
+                  "x-data": "seriesEditor(#{@series.id}, #{@series.interval_count}, '#{@series.interval_unit}', #{@series.shared})"
+                ) do
                   div(class: "section-header") do
                     h2(class: "section-title") do
                       span(class: "section-title-text") { "Series" }
@@ -30,7 +32,7 @@ module Ketchup
                     button(
                       class: "section-edit-btn",
                       "x-show": "!editing",
-                      "x-on:click": "editing = true; $dispatch('start-editing')"
+                      "x-on:click": "startEditing()"
                     ) do
                       plain "Edit"
                     end
@@ -38,7 +40,7 @@ module Ketchup
                       class: "section-edit-btn section-edit-btn--cancel",
                       "x-show": "editing",
                       "x-cloak": true,
-                      "x-on:click": "editing = false; location.reload()"
+                      "x-on:click": "cancel()"
                     ) do
                       plain "Cancel"
                     end
@@ -46,7 +48,7 @@ module Ketchup
                       class: "section-edit-btn",
                       "x-show": "editing",
                       "x-cloak": true,
-                      "x-on:click": "editing = false; $dispatch('stop-editing')"
+                      "x-on:click": "save()"
                     ) do
                       plain "Save"
                     end
@@ -59,8 +61,7 @@ module Ketchup
 
                   div(class: "series-note", id: "series-note-detail",
                       "x-bind:class": "{ 'series-note--editable': editing }",
-                      "data-value": @series.note || "",
-                      "data-series-id": @series.id.to_s)
+                      "data-value": @series.note || "")
 
                   dl(class: "detail-fields") do
                     dt { "Repeat every" }
@@ -70,45 +71,33 @@ module Ketchup
                     dd(
                       class: "detail-edit-interval",
                       "x-show": "editing",
-                      "x-cloak": true,
-                      "x-data": "intervalEditor(#{@series.id}, #{@series.interval_count}, '#{@series.interval_unit}')"
+                      "x-cloak": true
                     ) do
                       input(
                         type: "number",
                         class: "detail-input detail-input-count",
                         min: 1,
-                        "x-model.number": "count",
-                        "x-on:change": "save()"
+                        "x-model.number": "count"
                       )
                       select(
                         class: "detail-input detail-input-unit",
-                        "x-model": "unit",
-                        "x-on:change": "save()"
+                        "x-model": "unit"
                       ) do
                         INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
                       end
                     end
 
                     dt { "Shared" }
-                    dd("x-show": "!editing") do
-                      if @series.shared
-                        plain "Yes"
-                        render SharedIcon.new
-                      else
-                        plain "No"
-                      end
-                    end
+                    dd("x-show": "!editing") { @series.shared ? "Yes" : "No" }
                     dd(
                       "x-show": "editing",
-                      "x-cloak": true,
-                      "x-data": "sharedEditor(#{@series.id}, #{@series.shared})"
+                      "x-cloak": true
                     ) do
                       label(class: "detail-checkbox-label") do
                         input(
                           type: "checkbox",
                           class: "detail-checkbox",
-                          "x-model": "shared",
-                          "x-on:change": "save()"
+                          "x-model": "shared"
                         )
                         plain "Shared with everyone"
                       end
diff --git a/public/js/app.js b/public/js/app.js
index 54aebd0..27375cf 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -48,14 +48,6 @@ 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)}`,
-  })
-}
-
 OverType.setTheme({
   name: "ketchup",
   colors: {
@@ -86,7 +78,6 @@ OverType.setTheme({
 function initPanelEditors(container) {
   const noteDetail = container.querySelector("#series-note-detail")
   if (noteDetail) {
-    const seriesId = noteDetail.dataset.seriesId
     const initialNote = noteDetail.dataset.value || ""
 
     const [editor] = new OverType(noteDetail, {
@@ -99,17 +90,14 @@ function initPanelEditors(container) {
 
     const resizeNote = compactOverType(noteDetail)
 
+    noteDetail._overtype = editor
+    noteDetail._initialNote = initialNote
+
     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).then((r) => { if (r.ok) location.reload() })
-      })
-
       container.addEventListener("start-editing", () => {
         ta.style.pointerEvents = ""
         ta.readOnly = false
@@ -118,11 +106,6 @@ function initPanelEditors(container) {
       })
 
       container.addEventListener("stop-editing", () => {
-        const note = editor.getValue().trim()
-        if (note !== (initialNote || "").trim()) {
-          saveSeriesField(seriesId, "note", note).then((r) => { if (r.ok) location.reload() })
-          return
-        }
         ta.style.pointerEvents = "none"
         ta.readOnly = true
         if (resizeNote) requestAnimationFrame(resizeNote)
@@ -132,15 +115,45 @@ function initPanelEditors(container) {
 }
 
 document.addEventListener("alpine:init", () => {
-  Alpine.data("intervalEditor", (seriesId, initialCount, initialUnit) => ({
+  Alpine.data("seriesEditor", (seriesId, initialCount, initialUnit, initialShared) => ({
+    editing: false,
     count: initialCount,
     unit: initialUnit,
+    shared: initialShared,
+
+    startEditing() {
+      this.editing = true
+      this.$dispatch("start-editing")
+    },
+
+    cancel() {
+      this.editing = false
+      location.reload()
+    },
 
     save() {
+      this.editing = false
+      this.$dispatch("stop-editing")
+
+      const params = new URLSearchParams()
+      if (this.count !== initialCount) params.set("interval_count", this.count)
+      if (this.unit !== initialUnit) params.set("interval_unit", this.unit)
+      if (this.shared !== initialShared) params.set("shared", this.shared ? "1" : "0")
+
+      const noteEl = this.$el.querySelector("#series-note-detail")
+      if (noteEl?._overtype) {
+        const note = noteEl._overtype.getValue().trim()
+        if (note && note !== (noteEl._initialNote || "").trim()) {
+          params.set("note", note)
+        }
+      }
+
+      if (params.toString() === "") return
+
       fetch(`/series/${seriesId}`, {
         method: "PATCH",
         headers: { "Content-Type": "application/x-www-form-urlencoded" },
-        body: `interval_count=${encodeURIComponent(this.count)}&interval_unit=${encodeURIComponent(this.unit)}`,
+        body: params.toString(),
       }).then((r) => { if (r.ok) location.reload() })
     },
   }))
@@ -251,18 +264,6 @@ document.addEventListener("alpine:init", () => {
     },
   }))
 
-  Alpine.data("sharedEditor", (seriesId, initialShared) => ({
-    shared: initialShared,
-
-    save() {
-      fetch(`/series/${seriesId}`, {
-        method: "PATCH",
-        headers: { "Content-Type": "application/x-www-form-urlencoded" },
-        body: `shared=${encodeURIComponent(this.shared ? "1" : "0")}`,
-      }).then((r) => { if (r.ok) location.reload() })
-    },
-  }))
-
   // Series detail page editor
   initPanelEditors(document)