Extract Dashboard into separate Phlex components
Split the monolithic Dashboard into five focused components:
TaskCard, OverdueColumn, UpcomingColumn, SeriesDetail, and
NewSeriesForm. Dashboard now composes them.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index a933b00..74e80f1 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -3,6 +3,10 @@
require "phlex"
require_relative "layout"
+require_relative "overdue_column"
+require_relative "upcoming_column"
+require_relative "series_detail"
+require_relative "new_series_form"
module Views
INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
@@ -16,295 +20,22 @@ module Views
def view_template
render Layout.new(current_user: @current_user) do
div(class: @series ? "home home--series" : "home") do
- div(class: "column", "x-data": "sortable") do
- div(class: "column-header") do
- h2 { "Overdue" }
- nav(class: "sort-toggle") do
- button(
- "x-on:click": "sort = 'date'",
- "x-bind:class": "sort === 'date' && 'sort-active'",
- "x-bind:disabled": "sort === 'date'"
- ) { "Date" }
- button(
- "x-on:click": "sort = 'urgency'",
- "x-bind:class": "sort === 'urgency' && 'sort-active'",
- "x-bind:disabled": "sort === 'urgency'"
- ) { "Urgency" }
- end
- end
- task_list(@current_user.overdue_tasks.all.sort_by { |t| -t.urgency }, empty: "Nothing overdue.", sortable: true)
- end
-
- div(class: "column", "x-data": "upcoming") do
- div(class: "column-header") do
- h2 { "Upcoming" }
- nav(class: "sort-toggle") do
- button(
- "x-on:click": "showEmpty = !showEmpty",
- "x-bind:class": "showEmpty && 'sort-active'"
- ) { "Calendar" }
- end
- end
- upcoming_list(@current_user.upcoming_tasks.all)
- end
+ render OverdueColumn.new(
+ tasks: @current_user.overdue_tasks.all.sort_by { |t| -t.urgency },
+ selected_series: @series
+ )
+ render UpcomingColumn.new(
+ tasks: @current_user.upcoming_tasks.all,
+ selected_series: @series
+ )
if @series
- series_detail_sidebar
+ render SeriesDetail.new(series: @series)
else
- new_series_sidebar
- end
- end
- end
- end
-
- private
-
- def series_detail_sidebar
- active_task = @series.active_task
- 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
- button(
- class: "aside-heading-action",
- "x-show": "!editing",
- "x-on:click": "editing = true; $dispatch('start-editing')"
- ) { "Edit" }
- button(
- class: "aside-heading-action",
- "x-show": "editing",
- "x-on:click": "editing = false; $dispatch('stop-editing')"
- ) { "Done" }
- end
-
- div(class: "task-detail") do
- div(
- id: "series-note-detail",
- class: "task-detail-note",
- "data-value": @series.note || "",
- "data-series-id": @series.id.to_s
- )
- dl(class: "task-detail-fields") do
- dt { "Repeat every" }
- dd("x-show": "!editing") do
- plain interval_text(@series.interval_count, @series.interval_unit)
- end
- dd(
- class: "detail-edit-interval",
- "x-show": "editing",
- "x-cloak": true,
- "x-data": "intervalEditor(#{@series.id}, #{@series.interval_count}, '#{@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
- INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
- end
- end
-
- if active_task
- dt { "Due date" }
- dd(
- "x-show": "!editing",
- "x-text": "new Date('#{active_task[:due_date]}T00:00').toLocaleDateString()"
- ) { active_task[:due_date].to_s }
- dd(
- "x-show": "editing",
- "x-cloak": true,
- "x-data": "dueDateEditor(#{@series.id}, '#{active_task[:due_date]}')"
- ) do
- input(
- type: "date",
- class: "detail-input detail-input-date",
- "x-model": "dueDate",
- "x-on:change": "save()"
- )
- end
-
- if active_task.urgency > 0
- dt { "Urgency" }
- dd { "#{format("%.1f", active_task.urgency)}x" }
- end
- end
- end
-
- unless @series.completed_tasks.empty?
- div(class: "task-history") do
- h3 { "History" }
- ul do
- @series.completed_tasks.each do |ct|
- li(
- class: "task-history-item",
- "x-data": "historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"})"
- ) do
- div(class: "task-history-row") do
- span(class: "task-history-check") { "✓" }
- span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
- span(
- class: "task-history-add-note",
- "x-show": "!hasNote && !editing",
- "x-on:click": "edit()"
- ) { "add a note..." }
- end
- div(
- class: "task-history-note-editor",
- "data-value": ct[:note] || "",
- "x-show": "hasNote || editing",
- "x-ref": "editor"
- )
- end
- end
- end
- end
+ render NewSeriesForm.new
end
end
end
end
-
- def new_series_sidebar
- div(class: "column column-aside column-aside--new") do
- div(class: "column-header") do
- h2 { "New Series" }
- button(type: "submit", form: "new-series-form", id: "create-series-btn", class: "aside-heading-action", disabled: true) { "Create" }
- end
-
- form(method: "post", action: "/series", id: "new-series-form", class: "task-detail") do
- div(id: "series-note-editor", class: "task-detail-note")
-
- dl(class: "task-detail-fields") do
- dt { "Repeat every" }
- dd(class: "detail-edit-interval") do
- input(
- type: "number", name: "interval_count",
- class: "detail-input detail-input-count",
- min: 1, value: 1, required: true
- )
- select(name: "interval_unit", class: "detail-input detail-input-unit", required: true) do
- INTERVAL_OPTIONS.each_with_index { |(val, label), i| option(value: val, selected: i == 0) { label } }
- end
- end
-
- dt { "First due date" }
- dd do
- input(
- type: "date", name: "first_due_date",
- class: "detail-input detail-input-date",
- value: Date.today.to_s, required: true
- )
- end
- end
- end
- end
- end
-
- def task_list(tasks, empty:, sortable: false)
- if tasks.empty?
- p(class: "empty") { empty }
- else
- ul(class: "task-list") do
- tasks.each do |task|
- attrs = { class: "task-item" }
- if sortable
- attrs[:"data-urgency"] = format("%.4f", task.urgency)
- attrs[:"data-due-date"] = task[:due_date].to_s
- end
- li(**attrs) { task_card(task, sortable: sortable) }
- end
- end
- end
- end
-
- def upcoming_list(tasks)
- if tasks.empty?
- p(class: "empty") { "Nothing upcoming." }
- else
- tasks_by_date = tasks.group_by { |t| t[:due_date] }
-
- ul(class: "task-list") do
- current_month = Date.today.month
- horizon = Date.today + 91
- past_horizon = false
- last_date = tasks.last[:due_date]
- last_task_in_window = tasks_by_date.keys.select { |d| d <= horizon }.max || (Date.today - 1)
- (Date.today..last_date).each do |date|
- day_tasks = tasks_by_date[date]
- empty = day_tasks.nil?
- next if empty && date > last_task_in_window && date <= horizon
- if date.month != current_month
- current_month = date.month
- li(class: "calendar-month", "x-show": "showEmpty") if date <= horizon
- end
- if empty && date > horizon
- unless past_horizon
- past_horizon = true
- li(class: "calendar-horizon", "x-show": "showEmpty") do
- span { "3 months" }
- end
- end
- next
- end
- weekend = date.saturday? || date.sunday?
- day_attrs = { class: ["calendar-day", ("calendar-day-empty" if empty), ("calendar-day-weekend" if weekend)] }
- day_attrs[:"x-show"] = "showEmpty" if empty
- li(**day_attrs) do
- span(class: "calendar-date") { friendly_date(date) }
- end
- next if empty
- day_tasks.each do |task|
- li(class: ["task-item", ("calendar-day-weekend" if weekend)]) { task_card(task) }
- end
- end
- end
- end
- end
-
- def friendly_date(date)
- today = Date.today
- case date
- when today then "Today"
- when today + 1 then "Tomorrow"
- else
- if date < today + 7
- date.strftime("%A")
- else
- date.strftime("%b %-d")
- end
- end
- end
-
- def task_card(task, sortable: false)
- name = task[:note].lines.first&.strip || task[:note]
- overdue = task[:due_date] < Date.today
- selected = @series && @series.id == task[:series_id]
-
- div(class: ["task-card", ("task-overdue" if overdue), ("task-selected" if selected)]) do
- form(method: "post", action: "/series/#{task[:series_id]}/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 }
- end
- end
- end
-
- def interval_text(count, unit)
- "#{count} #{count == 1 ? unit : "#{unit}s"}"
- end
end
end
diff --git a/lib/ketchup/views/new_series_form.rb b/lib/ketchup/views/new_series_form.rb
new file mode 100644
index 0000000..6e95a14
--- /dev/null
+++ b/lib/ketchup/views/new_series_form.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module Views
+ class NewSeriesForm < Phlex::HTML
+ def view_template
+ div(class: "column column-aside column-aside--new") do
+ div(class: "column-header") do
+ h2 { "New Series" }
+ button(type: "submit", form: "new-series-form", id: "create-series-btn", class: "aside-heading-action", disabled: true) { "Create" }
+ end
+
+ form(method: "post", action: "/series", id: "new-series-form", class: "task-detail") do
+ div(id: "series-note-editor", class: "task-detail-note")
+
+ dl(class: "task-detail-fields") do
+ dt { "Repeat every" }
+ dd(class: "detail-edit-interval") do
+ input(
+ type: "number", name: "interval_count",
+ class: "detail-input detail-input-count",
+ min: 1, value: 1, required: true
+ )
+ select(name: "interval_unit", class: "detail-input detail-input-unit", required: true) do
+ INTERVAL_OPTIONS.each_with_index { |(val, label), i| option(value: val, selected: i == 0) { label } }
+ end
+ end
+
+ dt { "First due date" }
+ dd do
+ input(
+ type: "date", name: "first_due_date",
+ class: "detail-input detail-input-date",
+ value: Date.today.to_s, required: true
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ketchup/views/overdue_column.rb b/lib/ketchup/views/overdue_column.rb
new file mode 100644
index 0000000..0909550
--- /dev/null
+++ b/lib/ketchup/views/overdue_column.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "task_card"
+
+module Views
+ class OverdueColumn < Phlex::HTML
+ def initialize(tasks:, selected_series: nil)
+ @tasks = tasks
+ @selected_series = selected_series
+ end
+
+ def view_template
+ div(class: "column", "x-data": "sortable") do
+ div(class: "column-header") do
+ h2 { "Overdue" }
+ nav(class: "sort-toggle") do
+ button(
+ "x-on:click": "sort = 'date'",
+ "x-bind:class": "sort === 'date' && 'sort-active'",
+ "x-bind:disabled": "sort === 'date'"
+ ) { "Date" }
+ button(
+ "x-on:click": "sort = 'urgency'",
+ "x-bind:class": "sort === 'urgency' && 'sort-active'",
+ "x-bind:disabled": "sort === 'urgency'"
+ ) { "Urgency" }
+ end
+ end
+
+ if @tasks.empty?
+ p(class: "empty") { "Nothing overdue." }
+ else
+ ul(class: "task-list") do
+ @tasks.each do |task|
+ li(
+ class: "task-item",
+ "data-urgency": format("%.4f", task.urgency),
+ "data-due-date": task[:due_date].to_s
+ ) do
+ render TaskCard.new(task: task, selected: selected?(task), sortable: true)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def selected?(task)
+ @selected_series && @selected_series.id == task[:series_id]
+ end
+ end
+end
diff --git a/lib/ketchup/views/series_detail.rb b/lib/ketchup/views/series_detail.rb
new file mode 100644
index 0000000..1fbb3c7
--- /dev/null
+++ b/lib/ketchup/views/series_detail.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module Views
+ class SeriesDetail < Phlex::HTML
+ def initialize(series:)
+ @series = series
+ end
+
+ def view_template
+ active_task = @series.active_task
+ 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
+ button(
+ class: "aside-heading-action",
+ "x-show": "!editing",
+ "x-on:click": "editing = true; $dispatch('start-editing')"
+ ) { "Edit" }
+ button(
+ class: "aside-heading-action",
+ "x-show": "editing",
+ "x-on:click": "editing = false; $dispatch('stop-editing')"
+ ) { "Done" }
+ end
+
+ div(class: "task-detail") do
+ div(
+ id: "series-note-detail",
+ class: "task-detail-note",
+ "data-value": @series.note || "",
+ "data-series-id": @series.id.to_s
+ )
+ dl(class: "task-detail-fields") do
+ dt { "Repeat every" }
+ dd("x-show": "!editing") do
+ plain interval_text(@series.interval_count, @series.interval_unit)
+ end
+ dd(
+ class: "detail-edit-interval",
+ "x-show": "editing",
+ "x-cloak": true,
+ "x-data": "intervalEditor(#{@series.id}, #{@series.interval_count}, '#{@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
+ INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
+ end
+ end
+
+ if active_task
+ dt { "Due date" }
+ dd(
+ "x-show": "!editing",
+ "x-text": "new Date('#{active_task[:due_date]}T00:00').toLocaleDateString()"
+ ) { active_task[:due_date].to_s }
+ dd(
+ "x-show": "editing",
+ "x-cloak": true,
+ "x-data": "dueDateEditor(#{@series.id}, '#{active_task[:due_date]}')"
+ ) do
+ input(
+ type: "date",
+ class: "detail-input detail-input-date",
+ "x-model": "dueDate",
+ "x-on:change": "save()"
+ )
+ end
+
+ if active_task.urgency > 0
+ dt { "Urgency" }
+ dd { "#{format("%.1f", active_task.urgency)}x" }
+ end
+ end
+ end
+
+ unless @series.completed_tasks.empty?
+ div(class: "task-history") do
+ h3 { "History" }
+ ul do
+ @series.completed_tasks.each do |ct|
+ li(
+ class: "task-history-item",
+ "x-data": "historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"})"
+ ) do
+ div(class: "task-history-row") do
+ span(class: "task-history-check") { "✓" }
+ span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
+ span(
+ class: "task-history-add-note",
+ "x-show": "!hasNote && !editing",
+ "x-on:click": "edit()"
+ ) { "add a note..." }
+ end
+ div(
+ class: "task-history-note-editor",
+ "data-value": ct[:note] || "",
+ "x-show": "hasNote || editing",
+ "x-ref": "editor"
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def interval_text(count, unit)
+ "#{count} #{count == 1 ? unit : "#{unit}s"}"
+ end
+ end
+end
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
new file mode 100644
index 0000000..f8f38b6
--- /dev/null
+++ b/lib/ketchup/views/task_card.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module Views
+ class TaskCard < Phlex::HTML
+ def initialize(task:, selected: false, sortable: false)
+ @task = task
+ @selected = selected
+ @sortable = sortable
+ end
+
+ def view_template
+ name = @task[:note].lines.first&.strip || @task[:note]
+ overdue = @task[:due_date] < Date.today
+
+ div(class: ["task-card", ("task-overdue" if overdue), ("task-selected" if @selected)]) do
+ form(method: "post", action: "/series/#{@task[:series_id]}/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 }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ketchup/views/upcoming_column.rb b/lib/ketchup/views/upcoming_column.rb
new file mode 100644
index 0000000..9ee4790
--- /dev/null
+++ b/lib/ketchup/views/upcoming_column.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "task_card"
+
+module Views
+ class UpcomingColumn < Phlex::HTML
+ def initialize(tasks:, selected_series: nil)
+ @tasks = tasks
+ @selected_series = selected_series
+ end
+
+ def view_template
+ div(class: "column", "x-data": "upcoming") do
+ div(class: "column-header") do
+ h2 { "Upcoming" }
+ nav(class: "sort-toggle") do
+ button(
+ "x-on:click": "showEmpty = !showEmpty",
+ "x-bind:class": "showEmpty && 'sort-active'"
+ ) { "Calendar" }
+ end
+ end
+
+ if @tasks.empty?
+ p(class: "empty") { "Nothing upcoming." }
+ else
+ render_calendar
+ end
+ end
+ end
+
+ private
+
+ def render_calendar
+ tasks_by_date = @tasks.group_by { |t| t[:due_date] }
+
+ ul(class: "task-list") do
+ current_month = Date.today.month
+ horizon = Date.today + 91
+ past_horizon = false
+ last_date = @tasks.last[:due_date]
+ last_task_in_window = tasks_by_date.keys.select { |d| d <= horizon }.max || (Date.today - 1)
+ (Date.today..last_date).each do |date|
+ day_tasks = tasks_by_date[date]
+ empty = day_tasks.nil?
+ next if empty && date > last_task_in_window && date <= horizon
+ if date.month != current_month
+ current_month = date.month
+ li(class: "calendar-month", "x-show": "showEmpty") if date <= horizon
+ end
+ if empty && date > horizon
+ unless past_horizon
+ past_horizon = true
+ li(class: "calendar-horizon", "x-show": "showEmpty") do
+ span { "3 months" }
+ end
+ end
+ next
+ end
+ weekend = date.saturday? || date.sunday?
+ day_attrs = { class: ["calendar-day", ("calendar-day-empty" if empty), ("calendar-day-weekend" if weekend)] }
+ day_attrs[:"x-show"] = "showEmpty" if empty
+ li(**day_attrs) do
+ span(class: "calendar-date") { friendly_date(date) }
+ end
+ next if empty
+ day_tasks.each do |task|
+ li(class: ["task-item", ("calendar-day-weekend" if weekend)]) do
+ render TaskCard.new(task: task, selected: selected?(task))
+ end
+ end
+ end
+ end
+ end
+
+ def friendly_date(date)
+ today = Date.today
+ case date
+ when today then "Today"
+ when today + 1 then "Tomorrow"
+ else
+ if date < today + 7
+ date.strftime("%A")
+ else
+ date.strftime("%b %-d")
+ end
+ end
+ end
+
+ def selected?(task)
+ @selected_series && @selected_series.id == task[:series_id]
+ end
+ end
+end