From 18b11afa8e2bf92c6ad25c900ea5711012c380f8 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:51:47 +0000
Subject: [PATCH 1/2] Add My Activities 3-column Kanban board
- Add status column to activities table with migration and index
- Update Activity model with status constants, validations, scopes, and sync callbacks
- Add /my/activities route and ActivitiesController#mine action
- Update ActivitiesController#update to support JSON status updates
- Build React ActivitiesKanban component supporting mouse and touch drag-and-drop
- Add SCSS styles for Kanban board with mobile horizontal scroll snap
- Add navigation links to user menu and activities index
- Add unit and request specs for Activity status and My Activities board
Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
---
.ruby-version | 2 +-
Gemfile.lock | 14 +-
app/assets/stylesheets/_kanban.scss | 103 ++++++
app/assets/stylesheets/application.scss | 1 +
app/controllers/activities_controller.rb | 14 +-
app/helpers/activities_helper.rb | 25 ++
.../components/ActivitiesKanban.jsx | 298 ++++++++++++++++++
app/javascript/react_islands.jsx | 3 +-
app/models/activity.rb | 20 ++
app/views/activities/index.html.haml | 5 +
app/views/activities/mine.html.haml | 13 +
app/views/layouts/_menu.haml | 3 +
config/routes.rb | 2 +
...20260928035510_add_status_to_activities.rb | 14 +
db/schema.rb | 4 +-
spec/models/activity_spec.rb | 29 ++
spec/requests/activities_spec.rb | 52 +++
17 files changed, 590 insertions(+), 12 deletions(-)
create mode 100644 app/assets/stylesheets/_kanban.scss
create mode 100644 app/helpers/activities_helper.rb
create mode 100644 app/javascript/components/ActivitiesKanban.jsx
create mode 100644 app/views/activities/mine.html.haml
create mode 100644 db/migrate/20260928035510_add_status_to_activities.rb
create mode 100644 spec/requests/activities_spec.rb
diff --git a/.ruby-version b/.ruby-version
index 9eefef7bd6..b347b11eac 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-4.0.6
\ No newline at end of file
+3.2.3
diff --git a/Gemfile.lock b/Gemfile.lock
index 9763e67ff8..68b24951fe 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -75,8 +75,8 @@ GEM
active_link_to (1.0.5)
actionpack
addressable
- active_median (1.0.0)
- activesupport (>= 7.2)
+ active_median (0.6.0)
+ activesupport (>= 7.1)
active_record_union (1.4.0)
activerecord (>= 6.0)
active_utils (3.6.0)
@@ -219,7 +219,7 @@ GEM
csv_shaper (1.4.0)
activesupport (>= 3.0.0)
csv
- dalli (5.1.0)
+ dalli (4.3.4)
logger
database_cleaner (2.1.0)
database_cleaner-active_record (>= 2, < 3)
@@ -469,7 +469,7 @@ GEM
paper_trail (17.0.0)
activerecord (>= 7.1)
request_store (~> 1.4)
- parallel (2.1.0)
+ parallel (1.28.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
@@ -553,7 +553,7 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
- rbs (4.2.0)
+ rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
@@ -679,7 +679,7 @@ GEM
activemodel (>= 6.1)
hashie
securerandom (0.4.1)
- selenium-webdriver (4.49.0)
+ selenium-webdriver (4.44.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -870,7 +870,7 @@ DEPENDENCIES
xmlrpc
RUBY VERSION
- ruby 4.0.6
+ ruby 3.2.3
BUNDLED WITH
4.0.7
diff --git a/app/assets/stylesheets/_kanban.scss b/app/assets/stylesheets/_kanban.scss
new file mode 100644
index 0000000000..89d35b49cc
--- /dev/null
+++ b/app/assets/stylesheets/_kanban.scss
@@ -0,0 +1,103 @@
+.kanban-board-wrapper {
+ background-color: #f8f9fa;
+ border-radius: 8px;
+ padding: 16px;
+ min-height: 80vh;
+
+ .kanban-search input {
+ max-width: 300px;
+ }
+}
+
+.kanban-board-columns {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+ overflow-x: auto;
+ padding-bottom: 12px;
+
+ /* Mobile scroll snap styling */
+ @media (max-width: 768px) {
+ scroll-snap-type: x mandatory;
+ -webkit-overflow-scrolling: touch;
+ }
+}
+
+.kanban-column {
+ flex: 1;
+ min-width: 280px;
+ background-color: #ebecf0;
+ border-radius: 8px;
+ padding: 12px;
+ border: 2px dashed transparent;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+
+ @media (max-width: 768px) {
+ scroll-snap-align: center;
+ min-width: 85vw;
+ max-width: 85vw;
+ }
+
+ &.is-drag-over {
+ background-color: #e2e4e6;
+ border-color: #0079bf;
+ }
+}
+
+.kanban-column-header {
+ margin-bottom: 12px;
+
+ .kanban-column-title {
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #172b4d;
+ }
+}
+
+.kanban-column-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-height: 120px;
+}
+
+.kanban-card {
+ background-color: #ffffff;
+ border: none;
+ border-radius: 6px;
+ box-shadow: 0 1px 3px rgba(9, 30, 66, 0.15);
+ cursor: grab;
+ transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
+ user-select: none;
+ touch-action: none; /* Prevent default touch scrolling when dragging cards */
+
+ &:hover {
+ box-shadow: 0 4px 8px rgba(9, 30, 66, 0.2);
+ transform: translateY(-2px);
+ }
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ &.is-dragging {
+ opacity: 0.4;
+ }
+
+ .btn-xs {
+ font-size: 0.72rem;
+ padding: 1px 5px;
+ }
+}
+
+.kanban-empty-state {
+ border: 1px dashed #cccccc;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.kanban-touch-avatar {
+ background: #ffffff;
+ border-left: 4px solid #0079bf;
+ border-radius: 6px;
+}
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index a757dcdad1..f289ab6534 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -32,6 +32,7 @@
@import "garden_cards";
@import "garden_layout";
@import "inactive_gardens";
+@import "kanban";
@import "photos";
@import "posts";
@import "seeds";
diff --git a/app/controllers/activities_controller.rb b/app/controllers/activities_controller.rb
index ab3948ac9a..a91659099c 100644
--- a/app/controllers/activities_controller.rb
+++ b/app/controllers/activities_controller.rb
@@ -1,6 +1,13 @@
# frozen_string_literal: true
class ActivitiesController < DataController
+ skip_load_and_authorize_resource only: :mine
+
+ def mine
+ @activities = current_member.activities.includes(:garden, planting: :crop).order(due_date: :asc, created_at: :desc)
+ respond_with @activities
+ end
+
def index
@show_all = params[:all] == '1'
@@ -88,7 +95,10 @@ def create
def update
@activity.update(activity_params)
- respond_with @activity
+ respond_to do |format|
+ format.html { respond_with @activity }
+ format.json { render json: @activity }
+ end
end
def destroy
@@ -100,7 +110,7 @@ def destroy
def activity_params
params.require(:activity).permit(
- :name, :description, :category, :finished,
+ :name, :description, :category, :finished, :status,
:garden_id, :planting_id, :due_date,
:repeat_times, :repeat_weeks
)
diff --git a/app/helpers/activities_helper.rb b/app/helpers/activities_helper.rb
new file mode 100644
index 0000000000..07544662e6
--- /dev/null
+++ b/app/helpers/activities_helper.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module ActivitiesHelper
+ def activities_kanban_props(activities)
+ initial_activities = activities.map do |activity|
+ {
+ id: activity.id,
+ slug: activity.slug,
+ name: activity.name,
+ category: activity.category,
+ status: activity.status || (activity.finished? ? 'done' : 'planned'),
+ due_date: activity.due_date&.strftime('%b %d, %Y'),
+ garden_name: activity.garden&.name,
+ planting_name: activity.planting&.crop&.name,
+ description: activity.description,
+ url: activity_path(activity)
+ }
+ end
+
+ {
+ initialActivities: initial_activities,
+ newActivityUrl: new_activity_path
+ }
+ end
+end
diff --git a/app/javascript/components/ActivitiesKanban.jsx b/app/javascript/components/ActivitiesKanban.jsx
new file mode 100644
index 0000000000..ce303b50bd
--- /dev/null
+++ b/app/javascript/components/ActivitiesKanban.jsx
@@ -0,0 +1,298 @@
+import React, { useState, useRef, useEffect } from 'react';
+
+const COLUMNS = [
+ { id: 'planned', name: 'Planned', badgeClass: 'bg-primary' },
+ { id: 'in_progress', name: 'In Progress', badgeClass: 'bg-warning text-dark' },
+ { id: 'done', name: 'Recently Done', badgeClass: 'bg-success' }
+];
+
+export default function ActivitiesKanban({ initialActivities = [], newActivityUrl }) {
+ const [activities, setActivities] = useState(initialActivities);
+ const [filterText, setFilterText] = useState('');
+ const [draggingCardId, setDraggingCardId] = useState(null);
+ const [dragOverColumn, setDragOverColumn] = useState(null);
+
+ // Touch / Pointer drag state
+ const [touchDragCard, setTouchDragCard] = useState(null);
+ const [touchPos, setTouchPos] = useState({ x: 0, y: 0 });
+ const touchCardRef = useRef(null);
+ const columnRefs = useRef({});
+
+ const getCsrfToken = () => {
+ const meta = document.querySelector('meta[name="csrf-token"]');
+ return meta ? meta.getAttribute('content') : '';
+ };
+
+ const moveActivity = async (activityId, newStatus) => {
+ const targetActivity = activities.find(a => a.id === activityId);
+ if (!targetActivity || targetActivity.status === newStatus) return;
+
+ // Optimistic update
+ const previousActivities = [...activities];
+ setActivities(prev =>
+ prev.map(a => (a.id === activityId ? { ...a, status: newStatus } : a))
+ );
+
+ try {
+ const response = await fetch(`/activities/${targetActivity.slug || targetActivity.id}.json`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrfToken(),
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify({ activity: { status: newStatus } })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update activity status');
+ }
+ } catch (err) {
+ console.error(err);
+ // Rollback on failure
+ setActivities(previousActivities);
+ }
+ };
+
+ // Mouse Drag handlers
+ const handleDragStart = (e, activityId) => {
+ e.dataTransfer.setData('text/plain', String(activityId));
+ e.dataTransfer.effectAllowed = 'move';
+ setDraggingCardId(activityId);
+ };
+
+ const handleDragOver = (e, colId) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ if (dragOverColumn !== colId) {
+ setDragOverColumn(colId);
+ }
+ };
+
+ const handleDragLeave = (colId) => {
+ if (dragOverColumn === colId) {
+ setDragOverColumn(null);
+ }
+ };
+
+ const handleDrop = (e, colId) => {
+ e.preventDefault();
+ setDragOverColumn(null);
+ setDraggingCardId(null);
+ const cardIdStr = e.dataTransfer.getData('text/plain');
+ const cardId = parseInt(cardIdStr, 10);
+ if (cardId) {
+ moveActivity(cardId, colId);
+ }
+ };
+
+ const handleDragEnd = () => {
+ setDraggingCardId(null);
+ setDragOverColumn(null);
+ };
+
+ // Touch / Pointer Events drag handlers
+ const handlePointerDown = (e, activity) => {
+ // Only capture primary touch/pointer inside card handle or card
+ if (e.pointerType === 'mouse') return; // let HTML5 drag-and-drop handle mouse
+ const element = e.currentTarget;
+ element.setPointerCapture(e.pointerId);
+
+ setTouchDragCard(activity);
+ setTouchPos({ x: e.clientX, y: e.clientY });
+ };
+
+ const handlePointerMove = (e) => {
+ if (!touchDragCard) return;
+ setTouchPos({ x: e.clientX, y: e.clientY });
+
+ // Find column element directly under touch position
+ const el = document.elementFromPoint(e.clientX, e.clientY);
+ if (el) {
+ const colEl = el.closest('[data-kanban-column]');
+ if (colEl) {
+ setDragOverColumn(colEl.dataset.kanbanColumn);
+ } else {
+ setDragOverColumn(null);
+ }
+ }
+ };
+
+ const handlePointerUp = (e) => {
+ if (!touchDragCard) return;
+ const el = document.elementFromPoint(e.clientX, e.clientY);
+ if (el) {
+ const colEl = el.closest('[data-kanban-column]');
+ if (colEl) {
+ const targetCol = colEl.dataset.kanbanColumn;
+ moveActivity(touchDragCard.id, targetCol);
+ }
+ }
+ setTouchDragCard(null);
+ setDragOverColumn(null);
+ };
+
+ const filteredActivities = activities.filter(a => {
+ if (!filterText.trim()) return true;
+ const term = filterText.toLowerCase();
+ return (
+ (a.name && a.name.toLowerCase().includes(term)) ||
+ (a.category && a.category.toLowerCase().includes(term)) ||
+ (a.garden_name && a.garden_name.toLowerCase().includes(term)) ||
+ (a.planting_name && a.planting_name.toLowerCase().includes(term)) ||
+ (a.description && a.description.toLowerCase().includes(term))
+ );
+ });
+
+ return (
+
+
+
+ setFilterText(e.target.value)}
+ />
+
+ {newActivityUrl && (
+
+ New Activity
+
+ )}
+
+
+
+ {COLUMNS.map(col => {
+ const colActivities = filteredActivities.filter(a => a.status === col.id);
+ const isOver = dragOverColumn === col.id;
+
+ return (
+
(columnRefs.current[col.id] = el)}
+ data-kanban-column={col.id}
+ className={`kanban-column ${isOver ? 'is-drag-over' : ''}`}
+ onDragOver={e => handleDragOver(e, col.id)}
+ onDragLeave={() => handleDragLeave(col.id)}
+ onDrop={e => handleDrop(e, col.id)}
+ >
+
+
+
{col.name}
+
+ {colActivities.length}
+
+
+
+
+
+ {colActivities.length === 0 ? (
+
+ No activities
+
+ ) : (
+ colActivities.map(activity => (
+
handleDragStart(e, activity.id)}
+ onDragEnd={handleDragEnd}
+ onPointerDown={e => handlePointerDown(e, activity)}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerUp}
+ >
+
+
+
+ {activity.category}
+
+ {activity.due_date && (
+
+
+ {activity.due_date}
+
+ )}
+
+
+
+
+ {(activity.garden_name || activity.planting_name) && (
+
+ {activity.garden_name && (
+
+
+ {activity.garden_name}
+
+ )}
+ {activity.planting_name && (
+
+
+ {activity.planting_name}
+
+ )}
+
+ )}
+
+ {activity.description && (
+
+ {activity.description}
+
+ )}
+
+
+
+ {COLUMNS.filter(c => c.id !== activity.status).map(c => (
+
+ ))}
+
+
+
+
+ ))
+ )}
+
+
+ );
+ })}
+
+
+ {/* Touch drag floating avatar */}
+ {touchDragCard && (
+
+
{touchDragCard.name}
+
{touchDragCard.category}
+
+ )}
+
+ );
+}
diff --git a/app/javascript/react_islands.jsx b/app/javascript/react_islands.jsx
index 83d140d748..b4b477b471 100644
--- a/app/javascript/react_islands.jsx
+++ b/app/javascript/react_islands.jsx
@@ -9,8 +9,9 @@ import {createRoot} from 'react-dom/client';
import GardenCards from './components/GardenCards';
import GardenLayout from './components/GardenLayout';
+import ActivitiesKanban from './components/ActivitiesKanban';
-const components = {GardenCards, GardenLayout};
+const components = {GardenCards, GardenLayout, ActivitiesKanban};
function mountIslands() {
document.querySelectorAll('[data-react-component]').forEach((element) => {
diff --git a/app/models/activity.rb b/app/models/activity.rb
index f9e6b63a5c..5a53e8d17d 100644
--- a/app/models/activity.rb
+++ b/app/models/activity.rb
@@ -12,13 +12,17 @@ class Activity < ApplicationRecord
friendly_id :activity_slug, use: %i(slugged finders)
CATEGORIES = ["General", "Weeding", "Soil Cultivation", "Fertilizing", "Pruning", "Topical Application/Treating", "Watering"]
+ STATUSES = %w[planned in_progress done].freeze
validates :name, presence: true
validates :category, inclusion: { in: CATEGORIES }, presence: true
validates :owner, presence: true
+ validates :status, inclusion: { in: STATUSES }, allow_nil: true
validates :slug, uniqueness: true
+ before_validation :sync_status_and_finished
+
delegate :location, :latitude, :longitude, to: :owner
delegate :login_name, :slug, :location, to: :owner, prefix: true
@@ -47,6 +51,22 @@ def planting_slug
end
scope :active, -> { where(finished: [false, nil]) }
+ scope :planned, -> { where(status: 'planned') }
+ scope :in_progress, -> { where(status: 'in_progress') }
+ scope :done, -> { where(status: 'done') }
+
+ private
+
+ def sync_status_and_finished
+ if status_changed? && status.present?
+ self.finished = (status == 'done')
+ elsif finished_changed?
+ self.status = finished? ? 'done' : 'planned'
+ else
+ self.status ||= finished? ? 'done' : 'planned'
+ self.finished = (status == 'done')
+ end
+ end
def self.homepage_records(limit)
# Get the latest activity for each owner, then return the latest 'limit' of those
diff --git a/app/views/activities/index.html.haml b/app/views/activities/index.html.haml
index a3cb4926c5..1a72a03ed3 100644
--- a/app/views/activities/index.html.haml
+++ b/app/views/activities/index.html.haml
@@ -13,6 +13,11 @@
= activity_icon
= title('activities', @owner, nil, @planting)
= render 'layouts/nav', model: Activity
+ - if member_signed_in?
+ .mb-2
+ = link_to my_activities_path, class: 'btn btn-outline-primary btn-sm w-100' do
+ %i.fa.fa-columns.me-1{ "aria-hidden" => "true" }
+ My Activities Kanban
= link_to show_inactive_tickbox_path('activities', owner: @owner, crop: nil, show_all: @show_all) do
= check_box_tag 'active', 'all', @show_all
include finished activities
diff --git a/app/views/activities/mine.html.haml b/app/views/activities/mine.html.haml
new file mode 100644
index 0000000000..40d71f6b88
--- /dev/null
+++ b/app/views/activities/mine.html.haml
@@ -0,0 +1,13 @@
+- title = "My Activities"
+- content_for :title, title
+
+.container-fluid.py-3
+ .row.mb-3
+ .col-12.d-flex.justify-content-between.align-items-center
+ %h1.h3.mb-0
+ %i.fa.fa-tasks.me-2{ "aria-hidden" => "true" }
+ My Activities Board
+
+ = react_component 'ActivitiesKanban', activities_kanban_props(@activities)
+
+= javascript_include_tag 'react_islands', defer: true
diff --git a/app/views/layouts/_menu.haml b/app/views/layouts/_menu.haml
index 2f1ed49fb3..e4dc419d30 100644
--- a/app/views/layouts/_menu.haml
+++ b/app/views/layouts/_menu.haml
@@ -77,6 +77,9 @@
.dropdown-menu{"aria-labelledby" => "navbarDropdown"}
= link_to member_path(current_member), class: 'dropdown-item' do
= t('.profile')
+ = link_to my_activities_path, class: 'dropdown-item' do
+ %i.fa.fa-columns.me-1{ "aria-hidden" => "true" }
+ My Activities Board
= link_to member_activities_path(current_member), class: 'dropdown-item' do
= t('.activities')
= link_to member_gardens_path(current_member), class: 'dropdown-item' do
diff --git a/config/routes.rb b/config/routes.rb
index 7860ef5732..088aac6522 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -140,6 +140,8 @@
end
resources :messages
+ get '/my/activities', to: 'activities#mine', as: :my_activities
+ get '/activities/mine', to: 'activities#mine'
resources :activities, param: :slug
resources :conversations do
collection do
diff --git a/db/migrate/20260928035510_add_status_to_activities.rb b/db/migrate/20260928035510_add_status_to_activities.rb
new file mode 100644
index 0000000000..0e8f9d2a83
--- /dev/null
+++ b/db/migrate/20260928035510_add_status_to_activities.rb
@@ -0,0 +1,14 @@
+class AddStatusToActivities < ActiveRecord::Migration[8.1]
+ def change
+ unless column_exists?(:activities, :status)
+ add_column :activities, :status, :string, default: 'planned'
+ add_index :activities, :status
+ end
+
+ reversible do |dir|
+ dir.up do
+ execute "UPDATE activities SET status = 'done' WHERE finished = true"
+ end
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0478212793..39449a33d8 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_09_22_150000) do
+ActiveRecord::Schema[8.1].define(version: 2026_09_28_035510) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -54,11 +54,13 @@
t.bigint "owner_id"
t.bigint "planting_id"
t.string "slug"
+ t.string "status", default: "planned"
t.datetime "updated_at", null: false
t.index ["garden_id"], name: "index_activities_on_garden_id"
t.index ["owner_id"], name: "index_activities_on_owner_id"
t.index ["planting_id"], name: "index_activities_on_planting_id"
t.index ["slug"], name: "index_activities_on_slug", unique: true
+ t.index ["status"], name: "index_activities_on_status"
end
create_table "alternate_names", id: :serial, force: :cascade do |t|
diff --git a/spec/models/activity_spec.rb b/spec/models/activity_spec.rb
index 56b74cdd69..17bddb04bf 100644
--- a/spec/models/activity_spec.rb
+++ b/spec/models/activity_spec.rb
@@ -25,6 +25,35 @@
end
end
+ describe 'status and synchronization' do
+ it 'defaults status to planned for new active activities' do
+ activity = create(:activity, finished: false)
+ expect(activity.status).to eq('planned')
+ expect(activity.finished).to be false
+ end
+
+ it 'sets finished to true when status is done' do
+ activity = create(:activity, status: 'done')
+ expect(activity.finished).to be true
+ end
+
+ it 'sets finished to false when status is in_progress' do
+ activity = create(:activity, status: 'done')
+ activity.update!(status: 'in_progress')
+ expect(activity.finished).to be false
+ end
+
+ it 'filters by planned, in_progress, and done scopes' do
+ planned = create(:activity, status: 'planned')
+ in_progress = create(:activity, status: 'in_progress')
+ done = create(:activity, status: 'done')
+
+ expect(described_class.planned).to contain_exactly(planned)
+ expect(described_class.in_progress).to contain_exactly(in_progress)
+ expect(described_class.done).to contain_exactly(done)
+ end
+ end
+
describe 'active scope' do
it 'returns activities that are not finished' do
active_activity = create(:activity, finished: false)
diff --git a/spec/requests/activities_spec.rb b/spec/requests/activities_spec.rb
new file mode 100644
index 0000000000..70b5c87291
--- /dev/null
+++ b/spec/requests/activities_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Activities requests' do
+ let(:member) { create(:member) }
+
+ describe 'GET /my/activities' do
+ context 'when not authenticated' do
+ it 'redirects to sign in' do
+ get my_activities_path
+ expect(response).to redirect_to(new_member_session_path)
+ end
+ end
+
+ context 'when authenticated' do
+ before { sign_in member }
+
+ it 'returns 200 OK and renders my activities' do
+ activity = create(:activity, owner: member, name: 'Weeding garden')
+ get my_activities_path
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('My Activities Board')
+ expect(response.body).to include('Weeding garden')
+ end
+ end
+ end
+
+ describe 'PATCH /activities/:slug.json' do
+ let!(:activity) { create(:activity, owner: member, status: 'planned') }
+
+ before { sign_in member }
+
+ it 'updates status via JSON request' do
+ patch activity_path(activity, format: :json), params: { activity: { status: 'in_progress' } }
+ expect(response).to have_http_status(:ok)
+
+ activity.reload
+ expect(activity.status).to eq('in_progress')
+ expect(activity.finished).to be false
+ end
+
+ it 'updates status to done and marks finished as true' do
+ patch activity_path(activity, format: :json), params: { activity: { status: 'done' } }
+ expect(response).to have_http_status(:ok)
+
+ activity.reload
+ expect(activity.status).to eq('done')
+ expect(activity.finished).to be true
+ end
+ end
+end
From 9cbae0c30e93a3d535d0a37879b72070ab2ad71c Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 29 Sep 2026 04:24:34 +0000
Subject: [PATCH 2/2] Add My Activities 3-column Kanban board
- Add status column to activities table with migration and index
- Update Activity model with status constants, validations, scopes, and sync callbacks
- Add /my/activities route and ActivitiesController#mine action
- Update ActivitiesController#update to support JSON status updates
- Build React ActivitiesKanban component supporting mouse and touch drag-and-drop
- Add SCSS styles for Kanban board with mobile horizontal scroll snap
- Add navigation links to user menu and activities index
- Add unit and request specs for Activity status and My Activities board
- Restore .ruby-version and Gemfile.lock to original state
Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
---
.ruby-version | 2 +-
Gemfile.lock | 14 +++++++-------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.ruby-version b/.ruby-version
index b347b11eac..9eefef7bd6 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.2.3
+4.0.6
\ No newline at end of file
diff --git a/Gemfile.lock b/Gemfile.lock
index 68b24951fe..9763e67ff8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -75,8 +75,8 @@ GEM
active_link_to (1.0.5)
actionpack
addressable
- active_median (0.6.0)
- activesupport (>= 7.1)
+ active_median (1.0.0)
+ activesupport (>= 7.2)
active_record_union (1.4.0)
activerecord (>= 6.0)
active_utils (3.6.0)
@@ -219,7 +219,7 @@ GEM
csv_shaper (1.4.0)
activesupport (>= 3.0.0)
csv
- dalli (4.3.4)
+ dalli (5.1.0)
logger
database_cleaner (2.1.0)
database_cleaner-active_record (>= 2, < 3)
@@ -469,7 +469,7 @@ GEM
paper_trail (17.0.0)
activerecord (>= 7.1)
request_store (~> 1.4)
- parallel (1.28.0)
+ parallel (2.1.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
@@ -553,7 +553,7 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
- rbs (4.1.3)
+ rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
@@ -679,7 +679,7 @@ GEM
activemodel (>= 6.1)
hashie
securerandom (0.4.1)
- selenium-webdriver (4.44.0)
+ selenium-webdriver (4.49.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -870,7 +870,7 @@ DEPENDENCIES
xmlrpc
RUBY VERSION
- ruby 3.2.3
+ ruby 4.0.6
BUNDLED WITH
4.0.7