現行コード(main @ 80f4742、#2 DDL / #26 REST / #52 スタック hardening 反映後)を読み直して起こした、推測なしの仕様。レビュー用。
最終更新: 2026-07-26 対象: Task(root = tasks#index) 関連: Working Memory
Task はアプリの中核リソースで、root → tasks#index。1件の Task は 1 ユーザーに属し、任意で 1 フォルダに属する。Markdown ノート、スケジュール(start/due/end)、アイゼンハワー軸(important/urgent)、優先度 enum、手動 position、正規化タグを持つ。
表示は リスト(ライブ検索+フィルタ)、カレンダー(FullCalendar)、アイゼンハワーマトリクスの3系統。Free プランは 30件まで。
tasks(MariaDB 11.4 / utf8mb4_uca1400_ai_ci)
| 列 | 型 | 備考 |
|---|---|---|
| id | bigint PK | |
| user_id | bigint NOT NULL | FK → users, ON DELETE CASCADE |
| task_folder_id | bigint NULL | FK → task_folders, ON DELETE SET NULL |
| title | varchar(255) NOT NULL | CHECK char_length(trim(title)) > 0 |
| notes | text NULL | Markdown ソース |
| status | int NOT NULL DEFAULT 0 | enum pending/completed/archived(#88)。CHECK IN (0,1,2)。index (user_id, status, position) |
| completed_at | datetime(6) NULL | completed と同期 |
| due/start/end_at | datetime(6) NULL | CHECK start ≤ due, start ≤ end |
| important / urgent | tinyint(1) NOT NULL DEFAULT 0 | アイゼンハワー軸 |
| priority | int NOT NULL DEFAULT 1 | CHECK IN (0,1,2)(enum low/med/high) |
| position | int NOT NULL DEFAULT 0 | 手動並び順 |
| created_at / updated_at | datetime(6) NOT NULL |
index: (task_folder_id) / (user_id, task_folder_id, position) / (user_id, completed, position)。関連: task_tags 経由 tags、pomodoro_sessions.task_id(SET NULL)。
Task)belongs_to :user
belongs_to :task_folder, optional: true
has_many :pomodoro_sessions, dependent: :nullify
has_many :task_tags, dependent: :destroy
has_many :tags, through: :task_tags
enum :priority, { low: 0, medium: 1, high: 2 }, default: :medium
before_save :sync_completed_at — completed 変更時に completed_at を now/nil に設定(フォーム・toggle・matrix いずれ経由でも正しく同期)。after_save :reconcile_tag_records, if: -> { @pending_tag_names } — 同一保存トランザクション内で task_tags を再構成(§6)。tag_namesdef tag_names # フォーム用のカンマ連結
@pending_tag_names ? @pending_tag_names.join(", ") : tags.map(&:name).join(", ")
end
def tag_names=(value) # 大小無視 dedupe(DBは ci 照合)。"Ruby, ruby" -> ["Ruby"]
@pending_tag_names = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq(&:downcase)
end
scope :completed, -> { where(completed: true) }
scope :pending, -> { where(completed: false) }
scope :ordered, -> { order(Arel.sql("completed ASC, deadline_at IS NULL, deadline_at ASC, created_at DESC")) }
ordered = 未完了→期限昇順(NULL 最後)→新しい順。
scope :search)q を空白分割し各語を AND。語の形式:
!filter — 属性フィルタ(§10)#tag — 正規化テーブル経由の完全一致(TaskTag.joins(:tag).where(tags:{name:})、#77)title/notes LIKE または タグ名 LIKE を EXISTS サブクエリで(tasks.tag キャッシュは廃止、#76)LIKE は sanitize_sql_like でエスケープ。空クエリは all。
validates :title, presence: truedeadline_at_not_before_start_at — deadline_at < start_at で無効(等号は許可=DB CHECK と一致、#75)。end_at_not_before_start_at — end_at < start_at で無効(#72)。task_folder_must_belong_to_user — 同一ユーザーのフォルダのみ許可(DB に複合FKが無いため、この app 層が防御線)。quadrant → :do|:plan|:delegate|:eliminate(important×urgent)overdue? → deadline_at && !completed? && deadline_at < nowreconcile_tag_records — self.tags = names.map { user.tags.create_or_find_by!(name:) }(レース安全・保存トランザクション内、#70/#74)。tasks.tag キャッシュは廃止済み。do_quadrant/plan_quadrant/delegate_quadrant/eliminate_quadrant(matrix を SQL で分割、#81)。resources :tasks do
collection { get :calendar; get :events; get :matrix; post :preview }
member { patch :move; patch :toggle }
end
root "tasks#index"
before_action :set_task, only: %i[show edit update destroy move toggle] → current_user.tasks.find(...)(他人の id は 404)。
| アクション | メソッド/パス | 用途 | レスポンス |
|---|---|---|---|
| index | GET /tasks | 検索+フィルタ+folder+ページング | HTML / Turbo Frame tasks_list |
| show | GET /tasks/:id | 詳細 | HTML |
| new/create | GET /tasks/new · POST /tasks | 作成(上限チェック) | 302 / 422 |
| edit/update | GET :id/edit · PATCH :id | 編集 | 303 / 422 |
| destroy | DELETE /tasks/:id | 削除 | Turbo Stream / 303 |
| toggle | PATCH :id/toggle | 完了 set(completed 必須・冪等。仮想属性→status) |
Turbo Stream / 303 |
| archive / restore | PATCH :id/archive · :id/restore | status を archived / pending に(#88) | Turbo Stream / 303 |
| reorder | PATCH :id/reorder | position 設定(フォルダ内手動順、#87) | 204 |
| move | PATCH :id/move | カレンダー or 象限移動 | 204 / 422 |
| calendar | GET /tasks/calendar | FullCalendar ページ | HTML |
| events | GET /tasks/events | イベントフィード(期間制限) | JSON |
| matrix | GET /tasks/matrix | マトリクス | HTML |
| preview | POST /tasks/preview | Markdown→HTML | JSON { html } |
Tasks::Query に分離)@task_folders = current_user.task_folders.active.ordered
# free-text q(=Task.search) と UIフィルタ(due/priority/status/quadrant/folder) を合成
relation = Tasks::Query.new(current_user.tasks, params).results
@tasks = paginate(relation.ordered.includes(:tags, :task_folder)) # ?page/?per_page(既定20・上限100)
Tasks::Query がユーザーの !-DSL(q)と UI フィルタ(明示スコープ)を分離。includes(:tags, :task_folder) でカード描画の N+1 回避。
@task = current_user.tasks.build(task_params)
outcome = current_user.with_lock do # 同時作成を直列化 (#45)
next :limit if current_user.task_limit_reached?
@task.save ? :ok : :invalid
end
# :limit → alert リダイレクト / :ok → notice / else → render :new 422
# completed は必須(無いと 400)。同じ値を2回でも結果は同じ=冪等。
@task.update(completed: ActiveModel::Type::Boolean.new.cast(params.require(:completed)))
# completed_at はコールバックで同期。turbo_stream.replace(@task) または 303
return render_move_error("ambiguous_operation") if move_params[:quadrant].present? && date_move?
attrs =
if move_params[:quadrant].present?
quadrant_attributes!(move_params[:quadrant]) # 未知 quadrant → 400
elsif date_move?
move_params.slice(:start_at, :end_at, :deadline_at).to_h.transform_values(&:presence)
else
return render_move_error("missing_operation") # 422
end
@task.update(attrs) ? head(:no_content)
: render_api_error("validation_failed", status: :unprocessable_content, fields: @task.errors.to_hash)
move_params = params.permit(:quadrant,:start_at,:end_at,:deadline_at)。quadrant_attributes! は未知値で ActionController::BadRequest(eliminate へは落とさない)。update 失敗でも 204 を返さない(従来バグを解消)。
from, to = event_window # ?start/?end(ISO8601) を解析・既定窓・366日クランプ
effective = "COALESCE(start_at, deadline_at)" # created_at フォールバック廃止
tasks = current_user.tasks
.where("start_at IS NOT NULL OR deadline_at IS NOT NULL") # 予定ありのみ
.where("#{effective} >= :from AND #{effective} < :to", from:, to:)
# → [{ id, title, start(iso8601), end, allDay:false, url, color, extendedProps:{completed} }]
params.expect(task: [ :title, :notes, :deadline_at, :completed, :start_at, :end_at,
:tag_names, :priority, :urgent, :important, :task_folder_id ])
tag_names(tag ではない)。position はフォームからは編集不可。
User::FREE_TASK_LIMIT = 30/task_limit_reached? = free? && tasks.count >= 30。create の with_lock 内でチェックするため、同時2リクエストが両方通ることは無い。超過時は t("tasks.limit_reached", count: 30) でリダイレクト。
tag_names= が @pending_tag_names(大小無視 dedupe)を退避 → after_save :reconcile_tag_records が同一トランザクション内で task_tags を再構成(user.tags.create_or_find_by!(name:)・レース安全)。tasks.tag キャッシュ列は #76 で廃止。検索は join 経由(プレーン語は EXISTS、#tag は完全一致)、カード/詳細は task.tags を描画。
MarkdownRenderer.call(source) が preview と保存表示の単一レンダラ。Redcarpet(filter_html・safe_links_only・rel="nofollow noopener noreferrer")→ sanitize allowlist(p/br/hr/strong/em/del/sup/code/pre/blockquote/ul/ol/li/a/h1-4/table…、属性 href/title/rel/target)。javascript:/data: はリンク化されない。
form_with method: :get の検索フォーム(Turbo Frame tasks_list 対象、search コントローラ input->submit change->submit)、折りたたみフィルタトレイ(due/priority/status/quadrant)、フォルダ chip 行(All / 各アクティブフォルダ / Manage)、turbo_frame_tag "tasks_list" にカード群+空状態+render "shared/pagination"。article.task-card、button_to toggle のチェック、タイトルリンク、ステータスバッジ、Markdown ノート、フッタ(期限+overdue スタイル・優先度バッジ・folder chip・tag chips)、操作ボタン(詳細/編集/削除 turbo_confirm)。markdown-editor Stimulus、preview_tasks_path へ POST)、start/due/end datetime、folder collection_select、tag_names、priority select、important/urgent/completed スイッチ。events: eventsUrlValue)。ドラッグ/リサイズ → PATCH moveUrl に JSON { start_at, end_at }(期限は変更しない、#73)。PATCH moveUrl に JSON { quadrant }(zone.dataset.quadrant)。requestSubmit() → Turbo Frame 更新。toggleFilters でトレイ開閉。/tasks/preview を fetch。補足:
move/toggleは strong params を通さずparams[...]を直接読むため、JSON でも form-encoded でも受理する(上記 JS は JSON body)。
!filter)important/imp・urgent/urg・completed/done・pending/todo・high・medium・low・today・tomorrow・week/thisweek・overdue・nodate/no-date・hasdate/date・do・plan・delegate・eliminate。加えて #tag とフリーテキスト。
tasks.*(index/search/placeholders/markdown/status/edit)と activerecord.attributes.task.*、ロケール en/ja/de/zh。スイッチは role="switch"、検索欄に aria-label、toggle ボタンに aria-label。
Folder は Task を分類する入れ物。Task は任意で 1 つの Folder に属す(tasks.task_folder_id、NULL=未分類)。Folder はユーザー単位で名前が一意(前後空白を除去し正規化、DB 照合は大小無視)。Folder を削除しても Task は消えず未分類へ戻る(FK ON DELETE SET NULL)。
設計原則: Folder は「分類」に徹する(Project のような期限/完了状態は持たない)。Project 的な管理が必要になったら、
task_foldersを肥大化させず別テーブルprojectsを検討(レビュー既出)。
task_folders)| 列 | 型 | 備考 |
|---|---|---|
| user_id | bigint NOT NULL | FK → users, ON DELETE CASCADE |
| name | varchar NOT NULL | UNIQUE (user_id, name)・trim 正規化 |
| position | int NOT NULL DEFAULT 0 | 手動並び順(scope=user)。index (user_id, position) |
| color / icon | varchar(32/64) | 視覚区別(#11 で追加) |
| description | text | |
| archived_at | datetime | ソフトアーカイブ(NULL=アクティブ) |
| parent_id | bigint NULL | 階層(#86)。自己参照 FK・ON DELETE SET NULL・同一ユーザー+循環禁止 |
# TaskFolder
belongs_to :user
has_many :tasks, dependent: :nullify
before_validation :normalize_name # name.strip
validates :name, presence: true, length: { maximum: 80 }, uniqueness: { scope: :user_id }
scope :active, -> { where(archived_at: nil) }
scope :ordered, -> { order(:position, :name) }
def archived? = archived_at.present?
# TaskFoldersController(CRUD + コマンド)
resources :task_folders, except: :show do
member { patch :archive; patch :restore; patch :reorder }
end
archive -> update!(archived_at: Time.current) # サーバが時刻を設定
restore -> update!(archived_at: nil)
reorder -> update!(position: params[:position].to_i); head :no_content
strong params は name のほか color/icon/position/description を段階的に公開予定(現状フォームは name 中心)。Task 側は task_folder_must_belong_to_user で他人のフォルダ割当を拒否。
All / 各アクティブフォルダ / Manage、active.ordered)。選択で ?task_folder_id= 絞り込み。📁 フォルダ名 バッジ。/task_folders)で作成・改名・削除。DDL ブロッカー(#5 FKカスケード / #6 所有者整合 / #11 folder列 / #13 並び順index)は実装済み。UI は増分で実装中:
GET /tasks/grouped):フォルダサイドバー(All Tasks・件数バッジ・Archived)+フォルダカードのグリッド。各カードがそのタスク(未分類は「Unfiled」カード)を保持。検索は Tasks::Query 経由、アーカイブはボード外。リスト⇄ボードの切替リンク付き。tasks.png ワイヤーフレーム準拠。board Stimulus コントローラでタスク行をドラッグ、フォルダカード(+Unfiled+サイドバーのフォルダ項目)へドロップして task_folder_id+position を1回の PATCH /tasks/:id/reorder で更新(reorder は folder_id 任意対応・他人のフォルダは404)。件数バッジは楽観更新。要ブラウザ視覚検証。folder-search コントローラで Search folders… を名前フィルタ、該当なし表示)。並べ替えは PATCH /tasks/:id/reorder で単一 position 更新(全体正規化は将来の任意改善)。archive/restore、リスト+ボードのサイドバー導線)。① フォルダ別グループ表示(折りたたみ+未分類+件数+ドラッグ)
<span class="wf__title">🗂 Tasks</span>
<span class="wf__chip" style="flex:1;text-align:left;color:var(--muted)">🔍 search…</span>
<span class="wf__btn">+ New</span>
<span class="wf__chip is-active">All</span>
<span class="wf__chip">📁 Work 4</span>
<span class="wf__chip">📁 Home 2</span>
<span class="wf__chip">🗄 Archived</span>
<span class="wf__spacer"></span>
<span class="wf__chip">⚙ Manage</span>
<div class="wf__fhead"><span>▾</span><span class="wf__dot" style="background:#3a6df0"></span>📁 Work<span class="wf__count">4</span></div>
<div class="wf__row"><span class="wf__check"></span><span class="wf__name">Ship release</span><span class="wf__pill">High</span><span class="wf__pill">~Fri</span><span class="wf__grip">⠿</span></div>
<div class="wf__row"><span class="wf__check done">✓</span><span class="wf__name done">Write changelog</span><span class="wf__pill">Med</span><span class="wf__grip">⠿</span></div>
<div class="wf__fhead"><span>▾</span><span class="wf__dot" style="background:#1f9d55"></span>📁 Home<span class="wf__count">2</span></div>
<div class="wf__row"><span class="wf__check"></span><span class="wf__name">Groceries</span><span class="wf__pill">Low</span><span class="wf__pill">today</span><span class="wf__grip">⠿</span></div>
<div class="wf__fhead" style="color:var(--muted)"><span>▸</span><span class="wf__dot" style="background:#98988f"></span>📁 Someday <span style="font-weight:400">(折りたたみ)</span><span class="wf__count">7</span></div>
<div class="wf__fhead"><span>▾</span>🗒 Unfiled<span class="wf__count">3</span></div>
<div class="wf__row"><span class="wf__check"></span><span class="wf__name">Call dentist</span><span class="wf__grip">⠿</span></div>
<div class="wf__note">行をフォルダ見出しへドラッグ → 再割当(<code>PATCH /tasks/:id {task_folder_id}</code>)/セクション内でドラッグ → 並べ替え(<code>position</code>)</div>
② フォルダ管理ページ(色/アイコン/並び順/アーカイブ)
<div class="wf__bar"><span class="wf__title">📁 Manage folders</span><span class="wf__spacer"></span><span class="wf__btn">+ New folder</span></div>
<div class="wf__row" style="border:1px solid var(--line);border-radius:9px"><span class="wf__grip">⠿</span><span class="wf__dot" style="background:#3a6df0"></span><span class="wf__name">📁 Work</span><span class="wf__pill">rename</span><span class="wf__pill">🎨</span><span class="wf__pill">archive</span></div>
<div class="wf__row" style="border:1px solid var(--line);border-radius:9px"><span class="wf__grip">⠿</span><span class="wf__dot" style="background:#1f9d55"></span><span class="wf__name">📁 Home</span><span class="wf__pill">rename</span><span class="wf__pill">🎨</span><span class="wf__pill">archive</span></div>
<div class="wf__row" style="border:1px solid var(--line);border-radius:9px"><span class="wf__grip">⠿</span><span class="wf__dot" style="background:#98988f"></span><span class="wf__name">📁 Someday</span><span class="wf__pill">rename</span><span class="wf__pill">🎨</span><span class="wf__pill">archive</span></div>
<div class="wf__note" style="padding:.2rem 0">── Archived ──</div>
<div class="wf__row" style="border:1px dashed var(--line);border-radius:9px;color:var(--muted)"><span>🗄</span><span class="wf__name">📁 2025 Q4</span><span class="wf__pill">restore</span><span class="wf__pill">delete</span></div>
<div class="wf__note">⠿ ドラッグで並べ替え(<code>PATCH /task_folders/:id/reorder</code>)/🎨 色+アイコン選択/archive は <code>archived_at</code> をサーバ設定</div>
③ タスク行の「フォルダ移動」(D&D の代替・アクセシブル経路)
<div class="wf__row" style="border:1px solid var(--line);border-radius:9px"><span class="wf__check"></span><span class="wf__name">Buy milk</span><span class="wf__chip">📁 Home ▾</span></div>
<div class="wf__mitem">— (未分類 / Unfiled)</div>
<div class="wf__mitem">📁 Work</div>
<div class="wf__mitem sel">📁 Home ✓</div>
<div class="wf__note">select/menu = <code>PATCH /tasks/:id {task_folder_id}</code>(キーボード操作可・D&D なしでも割当できる)</div>
| Page | パス | 役割 / 状態 |
|---|---|---|
| Tasks(フォルダボード) | GET /tasks/grouped | サイドバー+フォルダカード+未分類(実装済 2026-07-26・D&D は次増分) |
| Tasks(フラット) | GET /tasks | 現行:検索+フィルタ chip+ページング |
| Manage folders | GET /task_folders | 作成/改名/削除(現行)+色/並び/アーカイブ(構想) |
| New / Edit folder | GET /task_folders/new · :id/edit | フォーム(現行 name / 構想 color・icon・description) |
| Task(folder select) | GET /tasks/:id/edit | collection_select :task_folder_id(現行) |
① タスクのフォルダ割当(作成 / ドラッグ移動)
② フォルダのライフサイクル(状態遷移)
③ グループ表示のレンダリング
実装済み(2026-07-26): parent_id を追加し階層対応(#86・同一ユーザー+循環ガード)。position を使用開始(フォルダ選択時は position 順、
PATCH /tasks/:id/reorder、#87)。タスクに archived 状態を導入(status enum #88、Free上限は active のみ #90)。フォルダボード表示(GET /tasks/grouped・サイドバー+フォルダカード)+カード間 D&D 割当(boardコントローラ→reorderが folder_id 任意対応)+サイドバーのフォルダ検索を実装し、tasks.pngワイヤーフレームを一通り実装完了(#1 増分1–3)。ボードではbody:has(.tasks-board)でグローバルナビをハンバーガー裏のドロワーに畳み、フォルダサイドバー1枚だけに(二重サイドバー回避・wireframe 準拠)。任意の将来改善:フォルダ内の精密な reorder(position 全体正規化)・ボードをタスク既定表示に昇格。
/tasks(フォルダボード #1)に対して、実ブラウザで実際に作成・編集・ドラッグ移動・削除まで行う E2E スモーク。各操作はリロード後のサーバー状態で検証(=楽観 DOM 更新ではなく永続化を確認)。スクリプト実体:test/e2e/board_smoke.mjs(+test/e2e/README.md)。CI ではなく手動 dev スモーク(Rails テストがロジック回帰の一次担保)。
環境:
playwright-core+ システム Chromium(/usr/bin/chromium-browser,--no-sandbox、ブラウザDL不要)。対象は dev のみ(http://localhost:3001/ todo-dev-web)。prod 不可侵。使い捨て dev ユーザー(confirmed_at設定+Work/Personalフォルダ)で実施し、完了後にdestroy!。削除確認はdialogをaccept()。
| # | フロー | UI 操作 | アサーション(リロード後) |
|---|---|---|---|
| 1 | login | /users/sign_in → /tasks |
.tasks-board が描画される |
| 2 | create | New task → title 入力+folder=Personal → submit | Personal カードにそのタスクが出現 |
| 3 | update | そのタスクの Edit → title 変更 → save | 新 title を表示・旧 title は消える |
| 4 | move | 行を Personal → Work カードへ native HTML5 ドラッグ | 再取得後 Work に所属・盤面にちょうど1件(=移動済み) |
| 5 | delete | Delete(confirm を accept) | 盤面から消え・[data-task-id] 総数が −1 |
結果:
5 passed, 0 failed(2026-07-26,main)。native HTML5 DnD は Playwright の合成マウスでは発火しないため、dndHtml5()が共有DataTransferでdragstart/dragover/drop/dragendをdispatchEventする(board コントローラの Stimulus アクションが発火)。
test/e2e/folder_smoke.mjs)フォルダのライフサイクルも同様に実操作で検証:
| フロー | UI 操作 | アサーション(リロード後) |
|---|---|---|
| create | New folder → name → submit | サイドバーにフォルダ出現 |
| rename | Manage → Edit → 改名 → save | サイドバーが新名を反映・旧名は消える |
| search | サイドバー検索に入力 | 一致は残り非一致は隠れ・no-match 表示・クリアで復元 |
| move | タスク行をサイドバーのフォルダへ native ドラッグ(dropToSidebar) |
再取得後そのフォルダに所属 |
| delete | Manage → Delete folder | サイドバーから消え・所属タスクは Unfiled に降格 |
結果:
6 passed, 0 failed(2026-07-26,main)。フォルダのモデル/DB ロジック(parent_id・循環ガード・same-user・uniqueness・dependent nullify)はtest/features/models/planning/task_folder_test.rb、archive/restore/reorder はtask_folders_commands_test.rbが担保。
test/e2e/board_smoke.mjs)// native HTML5 DnD を DataTransfer で直接発火(合成マウスでは起きない)
async function dndHtml5(p, srcSel, dstSel) {
await p.evaluate(({ srcSel, dstSel }) => {
const src = document.querySelector(srcSel), dst = document.querySelector(dstSel)
const dt = new DataTransfer()
const fire = (el, t) => el.dispatchEvent(new DragEvent(t, { bubbles: true, dataTransfer: dt }))
fire(src,'dragstart'); fire(dst,'dragover'); fire(dst,'drop'); fire(src,'dragend')
}, { srcSel, dstSel })
}
// move: 行 → Work カードの dropzone(data-folder-id で解決)
const id = await row.getAttribute('data-task-id')
const workId = await cardTasks(p, 'Work').getAttribute('data-folder-id')
await dndHtml5(p, `#task_${id}`, `.folder-card__tasks[data-folder-id="${workId}"]`)
await board(p) // リロード=サーバー真実で検証
注意: Devise confirmable のため検証ユーザーは
confirmed_atが必須。ユーザー・データは完了後に必ず削除(dev 汚染回避)。実行手順はtest/e2e/README.md。
test/controllers/tasks_controller_test.rb)E2E は「実際に動く」ことの確認。レンダリング・分岐・D&D 永続化の回帰は以下の Rails テストが真の担保(assert_select + JSON PATCH、全 128 test green)。
| テスト | 検証 |
|---|---|
| index renders the folder board by default | GET /tasks がボード(.tasks-board)を返す |
| index with view=list renders the flat list | ?view=list で旧フラット一覧(#tasks.task-grid) |
| grouped path redirects to the canonical board at /tasks | /tasks/grouped → /tasks リダイレクト |
| grouped board renders folder cards with their tasks | サイドバー+フォルダカード+タスクが出る |
| groups tasks without a folder under Unfiled | フォルダ未設定は Unfiled カードへ |
| keeps archived tasks off the board | archived はボード非表示 |
| filters by search query | q が Tasks::Query 経由で効く |
| sidebar wires the folder search filter | folder-search コントローラ配線・data-folder-name |
| reorder reassigns the folder when dragged between cards | PATCH /tasks/:id/reorder が task_folder_id+position を更新 |
| reorder with blank folder moves the task to Unfiled | task_folder_id="" → nil |
| reorder rejects a folder owned by another user | 他人のフォルダ id は 404(current_user.task_folders.find) |
| reorder without a folder param leaves the folder untouched | position のみ更新(#87 の後方互換) |
更新(2026-07-26, main @734a2c0): レビューを Issue 化(親 #68)し大半を実装済み。 Q1→#75 でモデル/DB を
start ≤ due(等号可)に統一+end 検証追加(#72)。 Q3→#69 で move を strong params・未知quadrant 400・失敗は 422(204 握り潰し解消)。 Q4→#73 で events は予定ありのみ・COALESCE(start,due)、カレンダーは span のみ更新。 Q5→#81 で matrix を SQL スコープ化。 Q6→#79 で position は「フォルダ内手動順」と決定(ADR 0003、reorder は #1)。 加えて toggle は completed 必須で冪等化(#71)、タグ同期はトランザクション内+create_or_find_by!+大小無視 dedupe(#70/#74)、#tagは完全一致(#77)。 #76 でtasks.tagキャッシュ列を廃止(検索は join 経由)、#82 でTasks::Queryに UI フィルタと検索 DSL を分離。 #80 でスケジュール列をstart_at/deadline_at/end_atにリネーム(締切と予定スパンを名前で分離)。残 open: #83(FULLTEXT・future)。
✅ 解決 #75/#72Q1. モデルと DB を start ≤ due(等号可)に統一。end_at の検証も追加(DBエラーでなく 422)。旧: モデルが厳密で DB は等号許容という不一致。
✅ 解決 #76/#77Q2. tasks.tag キャッシュ列を廃止。検索は正規化テーブル経由(プレーン語は EXISTS、#tag は完全一致)。二重管理を解消。
✅ 解決 #69/#71Q3. move は params.permit +未知quadrant 400 +失敗 422(204 握り潰し解消)、toggle は params.require(:completed) で明示化。※専用アクションは維持(PATCH /tasks/:id に統一はしない)。
✅ 解決 #73/#80Q4. events は予定ありのみ・COALESCE(start_at, deadline_at)(created_at 廃止)。#80 で列を start_at/deadline_at/end_at にリネームし「締切」と「予定スパン」を名前で分離。※終日(date型 due_on)や all_day は今回は導入せず datetime のまま。
✅ 解決 #81Q5. matrix を SQL スコープ(do_quadrant 等)+includesで分割。pending 全件 Ruby 分割を廃止。
✅ 決定 #79⏳ UI は #1Q6. position は**「フォルダ内手動順」と決定**(ADR 0003)。並べ替え UI/エンドポイントは Folder 機能 #1 で提供予定(フォルダ自体の reorder は #42 実装済)。
public/tasks-spec.html ・ noindex ・ 出典:app/models/features/planning/task.rb, tasks_controller.rb, app/views/tasks/**, Stimulus controllers, routes.rb(@ 80f4742)。