現行コード(main)を読み直して起こした、推測なしの仕様。毎日の習慣のチェックイン・ストリーク表示。レビュー用。
最終更新: 2026-07-27 対象: Habit / HabitCheckin(resources :habits + checkins collection の POST/DELETE) 関連: Tasks · Timer · Life
Habits は、ユーザーごとに登録した習慣を日単位でチェックインし、**連続達成日数(ストリーク)**を表示する機能。/habits に直近 7 日のグリッドを出し、各日のマスを押すとその日の達成を toggle(達成↔未達成)する。
HabitCheckin(habit_id + done_on の 1 行 = その日達成)。done_on は (habit_id, done_on) で一意(DB unique index)。同じ日に 2 行は作れない。FREE_HABIT_LIMIT = 3、active のみ計上/Tasks #90 と統一)。Pro = 無制限。作成は行ロックで上限を厳密化(#45)。✅ レビュー対応状況(#113 完了): 下記 §9 の全 finding を修正・マージ済み。 🔴B ロックアウト footgun=permit から archived 削除(#116)/🔴A weekly は daily 固定 UI 化(#119、カラム・検証は残置)/🟠D・E・F checkin を REST 冪等分割(#114)/§9J・#4 date 厳格化+未来拒否(#115)/show 500=詳細ページ実装(#117・#124、completion_rate はここで使用)/H perf(#121)/G・focus a11y(#123)/I テスト網羅(#122)/日付境界=ユーザー TZ(#125・ADR 0005)。 §9 の重大度はレビュー指摘に沿って再評価済み(A🔴→🟠、F🟠→🔴、H/I/J 🟢→🟠)。
| テーブル | カラム | 型 / 既定 | 制約 |
|---|---|---|---|
habits |
user_id |
bigint, not null | index |
name |
string, not null | presence | |
description |
text | 任意 | |
color |
string | 既定 toddyi-priority-fallback / TOKEN_PATTERN |
|
frequency |
string, 既定 daily |
CHECK in (daily,weekly) | |
target_per_week |
integer, 既定 7 |
CHECK 1..7 | |
archived |
boolean, 既定 false |
— | |
habit_checkins |
habit_id |
bigint, not null | — |
done_on |
date, not null | unique (habit_id, done_on) |
CHECK 制約は DB 側の最終防衛線。frequency・target_per_week はモデル/DB には存在するが、下記の通りロジック未接続。
belongs_to :user / has_many :habit_checkins, dependent: :destroy。name presence、frequency inclusion(daily/weekly)、target_per_week numericality {in: 1..7}(#118)、color は TOKEN_PATTERN = /\A(?:#[0-9a-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\))\z/i。--habit-color に流し込む CSS インジェクション防御境界。空値は before_validation で既定値に正規化(#120)。active = where(archived: false)。done_on?(date) … habit_checkins.exists?(done_on: date)(1 日 1 クエリ)。toggle!(date) … 該当日の checkin があれば destroy→false、無ければ create!→true。current_streak(today = Date.current) … 全 checkin の done_on を Set 化し、today(未達成なら昨日)から連続する達成日を数える。done_count_in(range) / completion_rate(range) … 範囲内達成数 / 達成率(%)。いずれもビュー未使用。def current_streak(today = Date.current)
days = habit_checkins.pluck(:done_on).to_set
streak = 0; cursor = today
cursor -= 1 if !days.include?(cursor) # 今日未達成なら昨日から
while days.include?(cursor); streak += 1; cursor -= 1; end
streak
end
belongs_to :habit / validates :done_on, presence, uniqueness: { scope: :habit_id }。| ルート | アクション | 挙動 |
|---|---|---|
GET /habits |
index | active な習慣を created_at 昇順、直近7日 @days を用意 |
GET /habits/new |
new | 空フォーム |
POST /habits |
create | with_lock 内で上限判定→save。limit/ok/invalid で分岐 |
GET /habits/:id/edit |
edit | — |
PATCH/PUT /habits/:id |
update | 成功で see_other リダイレクト |
DELETE /habits/:id |
destroy | destroy!→リダイレクト |
GET /habits/:id |
show | 詳細ページ(月次ヒートマップ+現在/最長ストリーク+30日達成率)。#117 で実装(以前はアクション無しで 500) |
POST /habits/:id/checkins |
checkins#create | 厳格パースした date を create!(exists/race は成功扱い=冪等)→ morph でカード差し替え/html は 303(#114/#115/#123) |
DELETE /habits/:id/checkins |
checkins#destroy | date の行を delete_all(冪等)。旧 shallow DELETE /habit_checkins/:id(アクション無し)は廃止(#114) |
全アクションは current_user.habits 起点でスコープ(他人の habit id は 404)。
Date.iso8601(params[:date]) で厳格パース。非 ISO は「別の有効日」に化けさせず 422、未来日も 422(過去は移行のため許可)。7日グリッド/ストリークの Date.current はユーザーの TZ基準(#125・ADR 0005)。toggle! は find_by→create! の 2 段。二重 POST が競合すると両方 find で空→両方 create で 2 本目が RecordNotUnique を送出し 500 になりうる(DB unique が最終防衛)。行ロック/rescue なし(§9 🟠F)。target_per_week は計算に一切入らない(§9 🔴A)。チェックインの状態
index: ヘッダ+「新規」ボタン、#habits に習慣カードを縦積み。0 件で empty-state。
カード(_habit): 名前+🔥ストリーク、編集/削除、説明、7 日グリッド(各日 button_to POST)。--habit-color でアクセント色。
フォーム(_form): name / description / frequency(select) / target_per_week(1..7) / color。archived の入力欄は無い(§9 🔴B)。
<div class="wf__t">🔁 朝の運動 🔥 5</div>
<div class="wf__mode">毎朝30分ウォーキング</div>
<span class="wf__btn">月 21</span><span class="wf__btn pri">火 22</span><span class="wf__btn pri">水 23</span>
<span class="wf__btn pri">木 24</span><span class="wf__btn">金 25</span><span class="wf__btn pri">土 26</span><span class="wf__btn">日 27</span>
緑(pri)= 達成済みの日。押下ごとに toggle し、その習慣カードだけ Turbo Stream で差し替え。
habit_limit_reached? = free? && habits.count >= 3。create は current_user.with_lock 内で判定し、同時作成で上限を超えないようにする(#45)。habits_path に alert(habits.limit_reached)でリダイレクト。habits.count は archived 含む全件(active スコープでない)。archived が UI で作れない現状は実害なしだが、将来 archive を実装すると「アーカイブ済みも上限に含む」挙動になる(§9 🟠C 関連、Tasks #90 と同型)。habits.*(title/subtitle/new/empty/fields/frequency/limit_reached/confirm_delete)。曜日は l(d, format: "%a")。aria-label のアイコンボタン(Tasks/Folder と同じ流儀)。削除は turbo_confirm。button_to(フォーム submit)。達成状態は色(is-done)のみで、スクリーンリーダ向けの状態テキスト/aria-pressed が無い(§9 🟢G)。重大度: 🔴 重大 🟠 中 🟢 軽微
frequency(daily/weekly)と target_per_week(1..7)は保存できるが、UI(常に7日グリッド)とストリーク(毎日達成前提)に一切反映されない。weekly 習慣・「週N回」目標を選んでも挙動が変わらない。completion_rate はこの目標評価に使えるが未使用。→ 実装するか、UI から外して daily 固定にするかの判断が必要。
active スコープ・archived カラム・permit はあるが、フォームに archived 入力が無く、アーカイブ/復元ボタンも無い。archived な習慣は作成も復元もできない(デッド機能)。Tasks の position(#79)と同型。→ archive UI を足すか、カラムを落とすか。
モデルに達成率計算があるが、どのビュー/コントローラからも呼ばれない。A の週次目標 UI を作るなら活用、作らないなら削除。
DELETE /habit_checkins/:id ルートにアクションが無いhabit_checkins, only: [:create, :destroy] で destroy ルートが生成されるが、HabitCheckinsController に destroy が無い。叩くと AbstractController::ActionNotFound。トグルは create 一本なので、ルートから :destroy を外すのが妥当(または destroy を実装)。
POST が「反転」なので、リトライ/二重送信で状態が意図せず戻る。Tasks の toggle 冪等化(#71)と同型。明示的な達成状態(done=true/false)を受ける、または create/DELETE に分ける案。
find_by→create! の間に同日 POST が競合すると 2 本目が RecordNotUnique。DB unique が最終防衛だが 500 になる。find_or_create_by+rescue で握るか、行ロック(Tasks のタグ #74 と同型)。
達成状態が色のみ。aria-pressed や視覚以外の達成表示(✓)が無い。
done_on? が日ごとに EXISTS(7 × 習慣数クエリ)、current_streak が習慣ごとに全 checkin を pluck。件数が小さい現状は実害小だが、7日ぶんは @days の範囲で一括ロードしてメモリ判定にできる。
Habit / HabitCheckin にモデル/コントローラテストが皆無(Tasks/Life/Timer は有)。特に current_streak(今日未達成→昨日から、の分岐)と上限ロックは回帰テストが欲しい。
Date.parse は「今日」でなく「別の有効日」に着地する当初 🟢 で「静かに今日になる」と記述したが事実誤認。Date.parse("27") は rescue に入らず今月27日に着地する(範囲外の月日のみ ArgumentError→今日)。範囲外の日付は §9(4) の到達不能データにもつながるため 🟠。#115 で Date.iso8601 の厳格パース+未来日拒否に修正済み。
Q1. weekly / target_per_week(🔴A)は実装する? それとも当面 daily 固定にして UI と C のデッドコードを削る?
Q2. archived(🔴B)は archive/復元 UI を足す? それとも今はカラムごと見送り?
Q3. checkin の冪等化(🟠E)と競合対策(🟠F)は Tasks と同じ流儀で揃える?
Q4. :destroy ルート(🟠D)は外す方針でよい?
Q5. ストリークの「今日未達成なら昨日から数える」仕様は意図通り?(未達成の今日でも streak を 0 にしない)
Q6. 7日グリッドは常に直近7日固定でよい?(週の開始や月表示の要望は?)
🔴A・🔴B・🟠D・🟠E あたりを潰すと、Habits は「保存できる daily 習慣+ストリーク」として堅くなる想定。優先度の指示があれば Issue/サブ Issue 化して着手します。(→ #113 で全対応済み)
| ファイル | カバー範囲 |
|---|---|
test/features/models/tracking/habit_test.rb |
バリデーション(target_per_week 範囲・frequency・color 空正規化)/current_streak(今日達成・grace day・gap・zero)/longest_streak/done_on?/completion_rate・done_count_in/checkin 未来日拒否 |
test/controllers/features/tracking/habits_controller_test.rb |
index/new/edit 描画・update(成功+422)・destroy/archived footgun・target 422/checkin の POST/DELETE 冪等・morph・厳格日付(malformed/future/past)/Free 上限・Pro 無制限/per-user 404(show/edit/update/destroy)/TZ 境界(Asia/Tokyo で「今日」が翌日) |
test/features/models/accounts/user_test.rb |
time_zone 検証・既定 UTC/habit_limit_reached?(active のみ・Pro 無制限) |
test/e2e/habits_smoke.mjs・手動 dev・スクショ付き)playwright-core + system Chromium。実 UI を操作し、各操作をリロード後のサーバー状態で検証。board_smoke/folder_smoke/markdown_smoke と同じ流儀。
| フロー | 操作 | アサーション(リロード後) |
|---|---|---|
| login | サインイン → /habits |
ページ描画 |
| create | New habit → 名前 → submit | 一覧にカード出現 |
| check-in | 今日のマスを押す(POST)→ もう一度(DELETE) | 達成+streak 1 → 解除。両方永続化 |
| show | 詳細ページを開く | 月次ヒートマップ+ストリーク統計(habits_show.png) |
| edit | 改名 → save | 新名を表示・旧名は消える |
| delete | 削除(confirm を accept) | 消え・カード数 −1 |
結果: 6 passed, 0 failed(2026-07-27, main)。スクショは test/e2e/screenshots/(habits_index.png / habits_show.png)。ネイティブ D&D は無いので board/folder のような DataTransfer dispatch は不要。手順・シードは test/e2e/README.md。
Toddyi — Habits Dev 仕様書(レビュー用・noindex)。コード main 準拠。