Skip to content
This repository was archived by the owner on Aug 29, 2026. It is now read-only.

Latest commit

 

History

History
592 lines (527 loc) · 53.3 KB

File metadata and controls

592 lines (527 loc) · 53.3 KB

cc-tasks

Claude Code に依頼したいタスクをメモ・管理する、利用者 1 人向けの Web アプリ。 設計(画面・操作、データモデル、API)は docs/詳細設計.md が正。迷ったらそちらを見る。 (旧 docs/画面仕様.mddocs/仕様書_v0.1.md は 2026-07 に詳細設計.md へ統合・削除した。)

  • 人間は PWA から出先でタスクを放り込む(Google OAuth、許可メール 1 件)
  • 全 Claude Code 環境に効かせたい共通ルールを「ルール」画面で管理し、連結してコピーする
  • 完成後はこのアプリ自身のタスク管理をこのアプリで行う(ドッグフーディング)

MCP サーバー機能とタスクの経緯(notes)は 2026-07 に廃止した。 MCP は 「使いたい機能ではない」と判断して丸ごと削除(.mcp.json・API キー認証・Spring AI 依存ごと)。 notes も同時に廃止し、notes テーブルは SchemaMigrations が DROP する。 過去のドキュメントやコミットに出てくる list_tasks / add_note はもう存在しない。

構成

backend/    Java 25 + Spring Boot 4.1 + Spring Data JDBC + SQLite。REST のみ
frontend/   Vue 3 + Vite + TypeScript + Pinia + vite-plugin-pwa。SPA
Dockerfile  frontend build → backend build → JRE のマルチステージ。単一コンテナ

ロジックは ProjectService / TaskService / RuleService に置き、コントローラは薄く保つ。

開発の回し方

**ポートは開発と本番で入れ替わる。7000 は「人がブラウザで開くほう」**と決めてある —— 開発では Vite(HMR が効くのはこちら)、本番では単一コンテナの Spring。 そのぶん開発中の backend は 7001(application-dev.yml)。本番コンテナと開発サーバーは 同時に上げられない(どちらも 7000 を取る)ので、片方を止めるか PORT で逃がす。

バックエンド単体

cd backend
./gradlew bootRun --args='--spring.profiles.active=dev'   # :7001(dev だけ。本番は 7000)
./gradlew test

dev プロファイルは 認証を通さない(Google OAuth 無しで curl を叩けるようにするため)。 本番では絶対に有効化しない。DB は backend/data/cctasks.db に作られる。

フロントエンド

cd frontend
npm install
npm run dev       # :7000 → /api を :7001 にプロキシ
npm run build     # vue-tsc の型チェック込み

バックエンドを dev プロファイルで起動しておくこと。

IP 直打ち以外のホスト名で開くときは VITE_ALLOWED_HOSTS が要る。 Vite は localhost と IP アドレスにしか応答せず(DNS リバインディング対策)、ローカル DNS で 当てたドメインで開くと 403 になる(Blocked request. This host … is not allowed.)。 通したい名前は環境変数で渡す —— 開発マシンごとに違う値なので設定に直書きしない:

VITE_ALLOWED_HOSTS=dev.example.lan npm run dev   # カンマ区切りで複数可

単一コンテナで動かす(ローカルビルド)

cp .env.example .env   # 値を埋める
docker compose -f compose.build.yaml up --build

本番(GHCR + pull)

main への push で GitHub Actions(.github/workflows/docker-publish.yml)が ghcr.io/<owner>/cc-tasks をビルド・公開する。本番はビルドせず compose.yaml(image を pull)を使い、 docker compose pull && docker compose up -d で更新するだけ。タグは latestsha-xxxxxxx。 amd64 / arm64 の両方をネイティブランナーで並列ビルドして 1 マニフェストにまとめる (arm64 の無料 ubuntu-24.04-arm ランナーは public リポジトリ限定)。

データ(SQLite とセッション)はリポジトリ直下の data//data に bind マウントする。 名前付きボリュームだと実体が /var/lib/docker/volumes/ に隠れ、リポジトリのフォルダごと コピーして移行したときに黙って取り残されるため(実際にデータを飛ばしたことがある)。 バックアップは data/ をコピーするだけでよい。

イメージ内の実行ユーザーは非 root(uid 100)なので、bind マウント先の所有者が合わないと 書けずに落ちる(セッションの保存先ディレクトリを作成できません: /data/sessions)。 ホストに data/ が無い状態で up すると Docker がそれを root 所有で作るため、 何もしないと必ず踏む。data/ は追跡していないので、clone 直後は常にこの状態。 対策は2つ:

  • compose の user: で uid/gid を指定し、書かれるファイルをホストのユーザー所有にする (既定 10001:10001 = ホストに実在しない番号。data/ をホストから直接触りたいときは .envCCTASKS_UID/CCTASKS_GIDid -u/id -g を入れる)
  • 本体の前に cctasks-init サービス(同じイメージを root で起動)が chown -R する。 depends_on: service_completed_successfully で完了を待ってから本体が起動するので、 ディレクトリが無い環境でも、別の所有者でバックアップから戻したときも手作業が要らない

compose ファイルは 3 つある

どれか 1 つを直したら、他も同じコミットで揃える(サービス構成・環境変数・ポートなど)。

ファイル 用途 値の渡し方
compose.yaml 本番(GHCR から pull) ${...}.env
compose.build.yaml 手元でビルドして本番同等確認 ${...}.env
compose.standalone.example.yaml .env を置けない環境へ貼り付ける雛形 YAML に直書き

単体定義が要るのは、管理画面に YAML を貼り付けて起動する環境が実在するため。そこには .env もシェルの環境変数も無く ${...} を解決できないので、値を YAML に書くしかない。 編集箇所は先頭の 3 ブロック(x-settings / x-data-volume / x-run-as)に集約し、 アンカーで参照している ── chown 先と user: は同じ値でなければならず、 2 箇所に書くと片方だけ直す事故が起きるため。entrypoint$$0 は compose の エスケープで、sh -c '…' <値> の位置パラメータとしてアンカーの値を渡している。

編集の要らない値は編集ブロックに入れない(DB_PATH はサービスの environment に 直書き)。触らなくてよいものが並ぶと、貼り付ける人がどこを直せばよいのか読み取れなくなる ── <<: *settings はそのための合成で、compose.yaml 側と同じ置き場にそろう。

実値を書いたコピー(compose.standalone.yaml)は .gitignore 済み。 リポジトリに置くのは雛形(.example)だけで、シークレットは決してコミットしない。

どの compose にも mem_limit を置かない(受け付けない実行環境があり、 そこだけ設定が変わるとメモリの前提がファイルごとにずれるため)。ヒープ上限は Dockerfile の -XX:MaxRAMPercentage=70 で決まるが、コンテナのメモリ上限が 無ければホストの搭載量が基準になる。絞りたい場合は JAVA_OPTS-Xmx を 直接指定する(既定の他のオプションごと置き換わるので一式書くこと)。

CI と依存更新

PR と main への push で .github/workflows/ci.yml./gradlew testnpm run build(vue-tsc の型チェック込み)を回す。 docker-publish はビルドするだけでテストを実行しないため、マージ前の検証はこちらが担う。 依存更新は Dependabot(.github/dependabot.yml)が週 1 回 npm / Gradle / Docker ベースイメージ / GitHub Actions の更新 PR を作る (パッチ・マイナーはエコシステムごとに 1 本へグループ化、メジャーだけ個別 PR)。 更新 PR は ci が通ったのを確認してからマージする。

TypeScript のメジャー更新だけ ignore で止めてある。 TypeScript 7(ネイティブ移植版)は package の exports から ./lib/tsc を外しており、それを require.resolve する vue-tsc が 起動できない(ERR_PACKAGE_PATH_NOT_EXPORTED。vue-tsc 3.3.9 時点でも未対応で、peer は typescript: >=5.0.0 と広いため npm install では気づけず CI で落ちる)。上げられない PR が 出続けても判断は変わらないので止めている。vue-tsc が対応したら ignore を外す —— 外し忘れると TypeScript が 5.x に据え置かれたままになる。

動作確認の記録 (v0.1 時点)

項目 結果
REST API 一式 (curl) 全エンドポイント疎通。エラー形式も {"error":{"code","message"}} で統一
SQLite 永続化 再起動後もデータが残る。created_at は ISO 8601 の TEXT、archived は 0/1
PWA manifest / Service Worker 生成、未知のパスの直リンクも SPA にフォールバック
認証 未認証 /api は 401、CSRF 無し POST は 403、ログイン試行は 20 回/分で 429
Docker イメージ 122 MB。--memory 256m で起動 1.8 秒 / RSS 138 MB

ハマりどころ (触る前に読む)

SQLite × Spring Data JDBC

Spring Data JDBC は SQLite 方言を同梱していない。以下の 2 つが無いと起動すらしない/黙って壊れる。

  • SqliteDialect + SqliteDialectProviderMETA-INF/spring.factories で登録している。 消すと起動時に NoDialectException
  • JdbcConfig のコンバータ。特に InstantJdbcValue を返す書き込みコンバータでないと効かない。 素直に Converter<Instant, String> を書くと、Spring Data JDBC が先に列型を java.sql.Timestamp と決めてしまい、SQLite にエポックミリ秒が入る。

DDL は backend/src/main/resources/schema.sql。起動のたびに CREATE TABLE IF NOT EXISTS で流す。 ただしこれは新規 DB にしか効かないため、列を足すときは schema.sql と SchemaMigrations (起動時に冪等な ALTER を当てる)の両方を直す(マイグレーションツールは入れていない)。

テーブル定義の最終形は docs/データベース定義.md(列・型・意味・ 索引・ER 図・クラス図)。schema.sql / SchemaMigrations / エンティティのいずれかを触ったら、 同じコミットでこの文書も更新する —— DDL とマイグレーションを往復しないと最終形が読めなくなるため。

再起動後の最初の書き込みが極端に遅くなる問題への対策が 2 つ入っている。 JDBC URL の synchronous=NORMAL(WAL ではコミット毎の fsync 不要。遅いディスクの書き込み待ちの回避)と、 WriteWarmup(起動時に INSERT + DELETE を 1 コミットして、書き込み経路の JIT とディスクの 初回コストを前払い)。外すと低速なディスク(アイドル時に停止するものを含む)の環境で 初回保存が数秒〜十数秒待ちに戻る。

読み取り側(初回アクセスが極端に重い)にも対をなす対策が HttpWarmup に入っている。 起動直後に自分自身へ GET を投げて MVC・フィルタチェーンのクラスロードと JIT を前払いし (ローカル実測で初回 85ms → 5ms。遅いディスクではこのクラスロードが眠ったディスクからの jar 読みになり数秒〜十数秒に化ける)、さらに 5 分ごとに静的資材(jar 内)と SQLite を読んで ページキャッシュに留める(キャッシュヒットはディスク I/O ゼロなので遅いディスクでも待たされない)。あわせて SpaWebConfig で Vite のハッシュ付き /assets/**Cache-Control: max-age=1y, immutable を付け、再訪時の取り直しを無くしている。

OAuth クレデンシャル

spring.security.oauth2.client.registration.google.client-id を空文字で置くと Spring Boot の検証で起動が落ちる。そのため GoogleOAuthEnvironmentPostProcessorGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET が揃っているときだけ登録を組み立てる。 未設定でも起動はするが /api は 401 のままになる。

PUBLIC_BASE_URL は任意。設定すればリダイレクト URI の基点になり、未設定なら {baseUrl}(ForwardedHeaderFilter 適用後のリクエスト)から導出する。 ただし Tomcat の RemoteIpValveX-Forwarded-Proto: https を見ると X-Forwarded-Port が無い限りポートを 443 に固定し、非標準ポート公開だと {baseUrl} のポートが落ちて redirect_uri が Google 登録値と食い違う。 リバースプロキシによってはポートを X-Forwarded-Host ではなく Host ヘッダ (例 Host: cctasks.example.com:8443)でしか伝えてこないため、これが顕在化する。 これを避けるため ForwardedRedirectUriResolver が origin を自前導出する: スキーム=X-Forwarded-Proto、ホスト=X-Forwarded-Host(あれば)→無ければ Host ヘッダ。 Host はポートを保持しているのでポートが残る。 PUBLIC_BASE_URL を明示設定したときはテンプレートが絶対 URL なので書き換えは無効。

この自動導出には ALLOWED_REDIRECT_HOSTS が要る(ポート込み・カンマ区切り)。 ホストはリクエストヘッダ由来=攻撃者が自由に付けられるので、許可リストに載っている ホストでしか書き換えない(Host ヘッダ注入対策)。PUBLIC_BASE_URLALLOWED_REDIRECT_HOSTS も未設定だと自動導出は働かず、上の「ポートが落ちる」 問題がそのまま出る(起動時に WARN を出す)。どちらか一方は必ず設定すること。 Google Console の「承認済みリダイレクト URI」にはポート込みで登録が必要 (例 https://cctasks.example.com:8443/login/oauth2/code/google)。 セッション Cookie の Secureapplication.ymlあえて指定していない —— Tomcat がリクエストのスキームを見て自動で付けるので、http ローカルでは非 Secure、 https 本番では Secure になり、PUBLIC_BASE_URL 無しでも両方でログインできる。 ここを secure: true で固定すると http ローカルでセッションが載らずログインループになる。

X-Forwarded-Proto を送らないプロキシ配下では、この自動導出は成立しない (既定で付けない実装がある)。コンテナは HTTP しか受けないので、ヘッダが無ければ http と判断するしかなく、redirect_uri が http で組まれてログインできない。 この場合は PUBLIC_BASE_URL を設定する ── 想定されている唯一の回避手段。

フィルタの差し込み位置

ログインのレート制限は OAuth2AuthorizationRequestRedirectFilter手前 に入れる必要がある。 UsernamePasswordAuthenticationFilter の手前だと、認可リクエストのリダイレクトの方が 先に起きて素通りする。

バケットのキーに X-Forwarded-For を使ってはいけない。 ヘッダは攻撃者が自由に付けられ、 プロキシは消さずに後ろへ追記する(偽装値, 本物のIP)ので、先頭を採ると毎リクエスト別バケットに なり制限が丸ごと無効化される(実測: XFF を変えながら 30 連打で 429 が 0 回)。 request.getRemoteAddr() を使う —— forward-headers-strategy: nativeRemoteIpValve が 信頼できるプロキシを判定したうえで入れた値なので詐称できない。

規約

  • タスクの状態遷移に制約は設けない(手戻り・中止を許容)
  • PATCH は「null のフィールドは変更しない」部分更新。空文字は「消す」。 数値の id はこれだと「消す」を表せないので、PATCH /api/tasks/{id}projectId だけは 0(TaskService.UNLINK_PROJECT_ID)を「紐づけを外す」の意味に使う(id は 1 から振られるので衝突しない)。 フロント側の定数は api/types.tsUNLINK_PROJECT_ID
  • タスク(メモ)のプロジェクト紐づけは任意(project_id は nullable)。未紐づけで放り込み、後から紐づけられる
  • 一覧の並びの既定は作成日時降順(更新で順番が動くと探しづらいため updated_at ではなく created_at)。 そのうえで プロジェクト内だけは手動並び替えを効かせる(tasks.sort_order 昇順 → 作成日時降順)。 並び替えで 1, 2, 3, … を振り、新規タスクは 0 のままなのでグループの先頭に積まれる(放り込む UX を壊さない)。 sort_orderプロジェクト内 の順序なので、projectId で絞らない一覧では 使わず作成日時降順のままにする —— プロジェクトをまたいで番号が混ざると探しづらいため。 この分岐は SQL 側(ORDER BY CASE WHEN :projectId IS NULL THEN 0 ELSE sort_order END, …)と フロント側(ストアはフラットに作成日時降順、トップのグループ内だけ compareInProject)の両方に入っている
  • UX は「タスクを素早く放り込む」優先。トップ = タスク入力 + 未完了一覧(プロジェクトごとに折りたたみ、デフォルトは閉。開いたグループを localStorage cc-tasks-home-expanded に保存)。並びはプロジェクトの並び順(projects.sort_order)
  • プロジェクト選択の並びは「プロジェクト → 最後に『プロジェクトなし』」(トップの入力欄と タスク編集モーダルの両方)。トップの既定は先頭のプロジェクト —— ほとんどのタスクは どれかに属するので、毎回選ばせない。決めるのは初回の読み込みのときだけで、 引っ張って更新したときは選び直した値を残す(戻すと続けて入力しているあいだ邪魔になる)。 編集モーダルの既定はそのタスクの今のプロジェクトなので、先頭は選ばない
  • プロジェクト未設定のタスクは「未分類」グループとして一番下に出す(lib/groups.tswithUnlinkedGroup()TaskGroup.project が null のかたまり、key は none)。 他と同じ見た目・同じ折りたたみで描くが、見出しの並び替えと「編集」だけ持たせない —— プロジェクトではないので動かす先も編集する中身も無いため。 トップでは該当が 0 件でもグループを出す(withUnlinkedGroup の第 3 引数 keepEmpty) —— 放り込み先・ドラッグで紐づけを外す先になるため。完了一覧(/done)は 「置き場」の意味が無いので従来どおり 0 件なら出さない。ProjectGroups はこれを pinnedGroups として 並び替え対象(sortableGroups)の後ろに固定で連結する
  • トップにはタスクが 0 件のプロジェクトも出す(そこへ放り込む導線になるため)。 例外はアーカイブ済みで、こちらは「まだ残っているタスクの置き場」としてだけ出し、片付いたら一覧から消える
  • 並び替えは 行のどこでも長押し(400ms)してからドラッグ(frontend/src/lib/dragSort.ts。 Pointer Events でマウス・タッチ両対応)。☰ の掴み代は置かない —— 画面が狭く、カード幅を削りたくないため。 トップではグループ見出しでプロジェクトの並び、カードでそのグループ内のタスクの並びが変わる。 掴み代が無い代わりに、以下の手当てが全部必要(どれか外すと操作が壊れる):
    • 長押しの成立前に指が 8px 以上動いたら中止する。そうしないと普通のスクロールがドラッグに化ける
    • 成立後は touchmove非パッシブで preventDefault()(touch-action はジェスチャ開始時に 確定してしまい、後から効かせられない)。要素側の touch-action: none では代用できない
    • 指を離した直後の click(リンク遷移・グループの開閉)を 1 回だけ握りつぶす (行のルートに @click.capture="sorter.clickGuard")
    • pointermove / pointerup は行ではなく window で受ける(押している間だけ登録)。 行に張ると、並び替えで Vue が行を動かした瞬間に壊れる —— insertBefore は 内部でノードを一度取り外すので、そこでポインタキャプチャが暗黙に解放される。 以降イベントは指の下の要素へ飛び、行のハンドラには二度と来ない (症状は「一度並び替えたあと、指から離れた瞬間に追従が止まる」)。 同じ理由で setPointerCapture は使わない —— 解放される前提には立てないため
    • カード内のボタン・リンクは data-no-drag で除外する。特に ✳ は長押しを奪ってはいけない (iOS で「長押し → Safari で開く」を一度やってもらう必要がある。§ハンドオフ参照)。 合わせて .card には -webkit-touch-callout: none[data-no-drag] には default を当てて戻す
    • ドラッグ中は「下に引っ張って更新」と食い合うので、App.vue が isDragActive() を見て見送る 掴んだ行は translateY指に追従する(follow())。ここで効いてくるのが、 並び替えで行のレイアウト位置自体が動くこと —— なので基準は常に 「レイアウト上の位置 = 現在の rect から今当てている translateY を引いた値」で計算する。 並び替えた直後は DOM がまだ入れ替わっていないので nextTick でもう一度当て直す。 同じ理由で、挿入先を決める中心線の判定でも掴んでいる行だけは translateY を引いて測る (引かないと、指に貼り付いた行が自分自身の判定を乱して並びが決まらなくなる)。 段組み(広い画面の一覧)では判定が 2 次元になる。 縦 1 列なら「中心線より上か」で 前後が決まるが、段組みは上から下、次に右の段の順に流れるので、 「左の段にいるならその時点で前、同じ段なら中心線より上か」で見る。 「同じ段で中心線より上か」だけを見てはいけない —— 段の一番下にポインタがあると どの枚にも当たらず、末尾まで滑り落ちる。 掴んだ行も段組みのときだけ横に追従させる —— 縦 1 列で横に動かすと、指が斜めにぶれた ぶんカードが列から外れる。段組みかどうかは掴んだ時点で 1 回だけ測り (isMultiColumn。左端の違う要素が混ざっていれば段組み)、ドラッグ中は変えない。 判定は lib/dragSort.tsinsertionIndex に切り出してあり、別グループへ落としたときの 挿入位置(lib/taskMove.ts)も同じものを使う —— 別々に書くと、段組みでだけ 「並び替えは左右を見るのに、移動は縦しか見ない」という食い違いが出る。 この判定で見る行はコンテナの先頭から items の数だけに限る —— トップの .groups には 並び替え対象の後ろに対象外の「未分類」グループが同居しており、そこまで数えると 範囲外の挿入位置が出て splice が undefined を混ぜ、一覧の描画ごと壊れる (症状は「プロジェクトを一番下までドラッグするとプロジェクト・タスクが全部消える」) トップは全プロジェクトを出すとは限らない(タスクの無いアーカイブ済みは出ない)ため、 プロジェクトの並び替えは projects.reorderVisible() で「出ている分の枠だけ詰め替え」て全件の順に埋め戻してから送る (PUT /api/projects/order は全件の id を要求するため)。タスク側の PUT /api/tasks/order は逆に部分集合でよく、 同じプロジェクト(未分類なら projectId=null)のタスクだけを混ぜずに送る
  • カードを別のプロジェクトのグループまで運ぶと、そのプロジェクトへ移る (useDragSort{ dropZones: true } + lib/taskMove.ts)。仕組み:
    • グループの <section>data-drop-zone="{key}" を振り、ドラッグ中は document.elementFromPoint で指の下のゾーンを拾う。掴んだ行には pointer-events: none を当てる —— そうしないと指の下にあるのが「運んでいる行」自身になり、移動先を拾えない
    • 元と違うゾーンの上に居る間は元のリストの並び替えを止める(どのみち離せば移動になるので、 残りのカードが動いて見えるのは邪魔なだけ)。受け皿のグループには outline で枠を出す。 border だとその幅ぶんレイアウトがずれて、追従中のカードが飛ぶ
    • 保存は「PATCHprojectId を付け替え → 移動先グループを PUT /api/tasks/order で並べ直す」の 2 段。 落とした座標から移動先での挿入位置を割り出すので、離した位置にそのまま入る (段組みでは X も見る。DropTargetpointerX / pointerY の両方を運ぶ)
    • 逆(プロジェクト → 未分類)も同じようにできる。PATCH に projectId: 0 を送って紐づけを外し、 並べ直しは projectId: null で送る(§規約の PATCH の項)。トップでは未分類グループを 0 件でも描くので、落とす先は常にある
  • タスクの状態は未着手(todo)・着手中(in_progress)・完了(done)の 3 つ。 着手中は 2026-07 に一度廃止したが、「Claude Code に依頼はしたが動作確認が済んでおらず 閉じられない」を表すために同月復活した。トップの一覧に出るのは未完了(done 以外)= 未着手 + 着手中。廃止中に作られた DB は CHECK 制約が in_progress を許さないため、 SchemaMigrations が tasks テーブルを作り直して差し替える(SQLite は CHECK だけを 変えられない。廃止前の DB は CHECK が許したままなので対象外)
  • 「修正が大変そう」の印は状態とは別の列(tasks.flagged 0/1)。着手中かどうかとは 独立に「これは重い」を表すので、status に 4 つ目の値を足さずに列を分けてある。 付いたカードは面を水色に塗る(--flag-bg。状態バッジの灰・黄・緑と被らない色)。 切り替えはカード右上の旗のボタンだけ —— 状態と同じで、編集モーダルには置かない (同じことが 2 箇所からできると、どちらが正か分からなくなる)。 並び・絞り込みには効かせない(目立たせるだけ)。収集ではなく人が付ける印なので TaskService.create は常に false で作り、PATCH /api/tasks/{id}flagged で付け外しする。 書き出し(バックアップ)は印も運ぶ —— 付け直すのは人の判断なので、復元先で 分からなくなると困る。この列より前に書き出したファイルには入っていないので、 ExportedTask.flaggednull を許して印なしとして読む
  • 広い画面(64rem 以上)では一覧を新聞組みにする —— 上から詰めて 画面の下端まで来たら右の段へ折り返す(ProjectGroups.vue.groups を multicol に。 column-width: var(--reading-max) + column-fill: auto)。溢れたぶんは横スクロール。 グループ単位で段を割ってはいけない —— それだとタスク 1 件のプロジェクトで段の右側が 丸ごと空く(実際、最初はカードをグループごとにグリッドで並べていて無駄が出た)。 グループもカードも区別せず 1 本の流れとして流し込み、グループは段をまたいでよい。
    • multicol なのは、flex に「段の高さで折り返す」流し込みが無いため。 flex コンテナは段をまたいで分割できないので、.groups と中の .cards は 広い画面では display: block に戻す(gap ではなく margin で間隔を取る)。 カードは break-inside: avoid、グループ見出しは break-after: avoid (見出しだけ段末に残ると、どのグループの続きか読めない)
    • 段の高さは「画面の余り」から渡す。 multicol は確定した高さが無いと折り返せないので、 広い画面では .appheight: 100dvh にして本文(.app__main)の側をスクロールさせ、 ビューの .page(と トップの .list)を flex で継いで .groups に余りを渡す。 magic number で calc(100dvh - ○rem) と書かない —— 入力欄や見出しを足すたびにずれる
    • 本文が縦に流れるのが <main> になるので、下に引っ張って更新は window.scrollY だけでなく <main>scrollTop も見る(見ないと、中をスクロールしていても「最上部」と判定される)
    • トップでは入力欄も同じ流れに入れる(段組みの箱はビュー側の .column-flow が持ち、 ProjectGroups は「段をまたげる形」に徹する)—— 入力欄を流れの外に置くと、 その右側が空いたまま一覧が下からしか始まらない。 そのぶん 1 段の幅は入力欄の幅(--reading-max)にそろえる —— 段組みは全段が同じ幅になるので、段を細くすると入力欄まで細くなる。 カードだけ細くはできないと分かったうえでこの幅にしている(段の数より入力欄を採った)
    • 外枠(--content-max)は広い画面では頭打ちにしない。 ここで止めると使える段の数が その幅で決まってしまう。代わりに 器の幅を段の整数倍に詰めて中央へ寄せる (lib/columnFlow.ts)。理由は 2 つ —— ① column-width は下限でしかなく、段は器の幅いっぱいに引き伸ばされる (1920px に 46rem の段なら 2 段 × 58rem になり、入力欄まで間延びする)。 ② 段組みは左から詰めるので、折り返しが要らない量のときは右がまるごと空く。 幅は測ってから決める(決め打ちで狭めると、量が増えたときに横スクロールが早く始まる)。 段組みが効いている画面幅かも CSS から読む(column-width が px か)—— 境目の値を JS にも書くと片方だけ古くなる。読み物も --reading-max のまま
    • 境目の 64rem は main.css・App.vue・各ビュー・.groups に散っているので、 動かすなら全部そろえること。スマホ(主用途)の見え方は変えていない
    • 段組みにすると長押しドラッグの判定が 2 次元になる(§並び替えの項)
  • タスクの一覧・詳細・編集の専用画面はすべて廃止。未完了はトップ、完了は /done、 編集はカードを押してモーダル。ヘッダは「トップ / ルール」だけになった。 /done はトップの未完了一覧と同じ見せ方にするため ProjectGroups を共有する (:sortable="false" :project-editable="false"。完了分を並べ替える意味も、 そこからプロジェクトを編集する意味も無いため)。完了タスクを持つプロジェクトだけ出す —— トップと違って「放り込み先」の意味が無いので、0 件のプロジェクトは出さない。 完了分はストアに持たず /done のローカル state として GET /api/tasks?status=done で 開くたびに取り直す(ページングしていないので、完了が数百件まで増えたら重くなる)。
  • タスクの詳細画面と編集画面は廃止。編集は一覧のカード本文を押してモーダルで行う (TaskFormModal.vue)。カードに編集ボタンは置かない —— ボタンを増やすより、 カードそのものを押せる方が指の移動が少ない。/tasks/:id/tasks/:id/edit は ルートから消えており、古いブックマークはワイルドカードでトップに流れる。 物理削除は誤タップを避けるため一覧には置かず、編集モーダルの「削除」からだけ行う。 状態の切り替えはカードのボタンだけが持つ(未完了一覧では「着手」と「完了」、 着手中のタスクは色付きの「着手中」を押すと未着手に戻る、完了一覧では「未着手に戻す」)。 編集モーダルに状態の選択は置かない —— 同じことを 2 箇所でできると、どちらが正かが分からなくなるため。モーダルの保存は status を 送らないので、完了タスクの本文を直しても完了のまま残る。 カード内は「メモ」と「右カラム(上: 旗/コピー/✳、下: 着手・完了ボタン)」の 2 列。 右カラムを align-items: stretch で伸ばすことで、メモが複数行でも ボタンの下端がメモの下端に揃う。 完了タスクはトップ左下のリンクから /done で確認する。 編集モーダルの「プロジェクト」で 「プロジェクトなし」を選べば紐づけを外せる (保存時に projectId: 0 を送る。undefined だと「変更しない」になって外れない)
  • コピーは複製に見えないようクリップボードアイコンでカード右上(編集・完了ボタンの上)に置く。 左隣が「修正が大変そう」の旗、右隣が ✳ ハンドオフ
  • 背景クリックで閉じるモーダルは lib/backdropClose.tsbackdropClose() を使う (v-on="backdrop" で背景の <div class="modal"> に渡す。setup で 1 回だけ作る)。 @click.self="close" を直接置いてはいけない —— モーダル内のテキストを範囲選択して マウスを背景の上まで動かして離しただけで閉じてしまう。click は mousedown と mouseup の 共通祖先で発火するので、選択の始点が中身でも target が背景になり .self の判定を通るため (文言をコピーしたいだけなのに入力が消える)。backdropClose は mousedown も背景の上で 始まったときだけ閉じる。対象はタスク編集・ルール編集・プロジェクト編集の各モーダルと ルール画面の連結プレビュー
  • プロジェクト専用画面は持たない(廃止済み)。作成・編集・アーカイブ・並び替えはすべてトップで完結する: 新規は「未完了」見出しの右端の「+ プロジェクト」、編集は各グループ見出しの右端の「編集」、 どちらも ProjectFormModal.vue(v-if で出し入れする前提なのでフォームの初期値は setup で一度だけ組み立てる)。 /projects はルートから消したので、古いブックマークはワイルドカードでトップに流れる
  • アーカイブはモーダルの見出しと同じ行の右端のボタン(タスク編集モーダルの「削除」と同じ置き方)。 アーカイブ済みなら「アーカイブから戻す」に変わる。押すと入力中の内容ごと保存して閉じる(編集を捨てさせない)。 アーカイブできるのは未完了が 0 件のときだけ(戻すのは無条件)。片付いていないタスクごと トップから消えると放り込んだものを取りこぼすため。UI はボタンを disabled にし、 ProjectService.update でも弾く(REST を直接叩いても通らない)
  • プロジェクトの削除はアーカイブ済みのときだけ(DELETE /api/projects/{id})。 アーカイブ自体が「未完了 0 件」を条件にしているので、片付いたことを確かめる一段を 必ず通ってからでないと消えない。紐づくタスクは TaskRepository.deleteByProjectId で 同一トランザクションにまとめて消す(完了済みも巻き添え)。 ボタンはモーダル見出し行の一番右(「アーカイブから戻す」の右)。 確認ダイアログに件数は出さない —— ストアには未完了しか無く、アーカイブ済みは必ず 0 件なので、 「0 件」と言いながら完了済みを消すことになる
  • トップにはアーカイブ済みを出さない。代わりに一覧の一番下の「アーカイブしたプロジェクト →」から /archived(ArchivedProjectsView)へ(左は「完了したタスク →」)。この導線が唯一のアーカイブを戻す口なので消してはいけない。 見え方はトップと揃える必要があるので、グループの描画は ProjectGroups.vue に寄せて共有している (アーカイブ一覧は並べ替える意味が無いので :sortable="false")。グループ組み立ては lib/groups.ts
  • GET /api/projectsarchived パラメータに defaultValue を使ってはいけない。 Spring は「パラメータが空文字」のときも defaultValue で置き換えるため、 ?archived=(全件のつもり)が false に化けてアーカイブ済みが一件も返らなくなる。 未指定(null)と空文字は自前で分ける
  • タスクが持つのは title だけcontext / acceptance_criteria / out_of_scope は廃止した —— 出先で放り込む使い方では埋まらなかったため。これに伴い、トップの 「受け入れ条件などを詳しく書く →」リンクも撤去した(編集モーダルに残るのは タスク内容・プロジェクトのみ)。 一度も中身が書かれないまま廃止したので、既存 DB の 3 列も SchemaMigrationsDROP COLUMN で落とす(SQLite 3.35+ が必要。同梱の sqlite-jdbc は 3.53)
  • ✳ アイコン(コピーの隣)は Claude Code へのハンドオフ。タスク内容をプリフィルした https://claude.ai/code?prompt=…&repositories=… を新規タブで開く直リンク。 スマホは初期状態ではユニバーサルリンクで Claude アプリが開き、アプリはクエリを 引き継がないためプリフィルが失われる。空タブ + JS 遷移、中継ページ /handoff からの JS 遷移のどちらでもユニバーサルリンクの発火は回避できず撤去済み(再挑戦するとき用のメモ)。 効く回避は「リンク長押し → Safari で開く、を一度やる」で、iOS が以後ブラウザで開くのを覚える。 さらに iOS の PWA(スタンドアロン)では外部リンクがアプリ内ブラウザで開いて claude.ai の ログインセッションを共有しないため、navigator.standalone のときだけ href を x-safari-https://…(非公式スキーム、iOS 17+)にして Safari 本体で開かせている。 効かなくなったら素の URL に戻す。URL 組み立ては frontend/src/lib/claudeCode.tsrepositories はプロジェクトのリポジトリ URL のうち GitHub のみ owner/repo スラッグに 変換して付与し(規約リポジトリが設定されていればそれも常に足す。§ルール機能)、 プロンプトは約 4,500 文字で切り詰める(Web 版のプリフィル上限 5,000 文字対策)。 プロンプトの中身はタスク内容そのもので、タスク番号は付けない —— MCP 廃止後は Claude Code 側から cc-tasks を参照できず、番号を渡しても意味が無いため。 かわりに規約リポジトリが設定されていれば「まず規約リポジトリの CLAUDE.md (共通ルール)に従う」の一言を先頭に添える(セッションに含めるだけでは 他リポジトリの説明と誤読されうるので、従う対象だと明示する)
  • 下に引っ張って更新: PWA にはリロード手段が無いため App.vue がタッチジェスチャを検知し、 ビューが usePullToRefresh(frontend/src/lib/pullToRefresh.ts)で登録した再読込処理を呼ぶ。 未登録の画面はページ全体のリロードにフォールバック
  • フッター(横線の下)にビルド番号を全画面で出す(App.vue)。形式は 「JST 日付 + コミット短縮ハッシュ」(例 20260729-a8f447f)。SW の旧キャッシュや 未更新のイメージを見ていないかをここで見分ける(修正を入れたのに直らないときは、まずこれを見る)。 値は vite.config.tsdefine__BUILD_NUMBER__ として注入する。 Docker のビルドコンテキストには .git を含めないため、docker-publish が build-arg GIT_SHA で渡す。手元のビルドは git から直接引き、どちらも無ければ nogit
  • 通信中は画面全体を半透明の白いオーバーレイで覆う(lib/network.ts が進行中リクエストを数え、 API クライアントが開始・終了を報告、App.vue が描画)。表示は 0.3 秒待ち、すぐ終わる通信では 出さない(保存のたびに画面が点滅するため)。通信失敗はダイアログを 1 回出して OK で ログイン画面へ戻す。「通信失敗」= fetch の失敗・タイムアウト(30 秒。低速なディスクが 眠った環境の初回アクセスを考慮して短くしすぎない)・ゲートウェイエラー(502/503/504。 リバースプロキシ越しだとバックエンドが落ちていても fetch 自体は成功するため)。 バックエンド自身が返す 4xx/5xx の API エラーは従来どおり各画面のバナー。 認証確認(/api/me)が済むまで RouterView を描画しない(未認証で描けるのは ログイン画面だけ)。詳細は docs/詳細設計.md §14
  • 開発時は spring-boot-devtools で自動再起動(bootJar には入らず本番では無効)。反復は ./dev.sh(backend dev + 継続コンパイル + Vite HMR)で回す。開くのは :7000、dev は認証なし
  • セッションはディスク永続化(server.servlet.session.persistent、store-dir は SESSION_DIR=/data/sessions)。再起動・再デプロイでも再ログイン不要。timeout は 30d。 Cookie にも max-age: 30d を明示する —— 無指定だとブラウザセッション Cookie に なり、モバイル OS が PWA のプロセスを回収してしばらく経つとブラウザが Cookie を捨て、 サーバーのセッションが生きていてもログインし直しになる(実際に起きた)
  • 管理系エンドポイント(Actuator 等)は追加しない

タスクのバックアップ(書き出し / 読み込み)

未完了タスクを JSON で持ち出し、貼り付けて戻せる(TaskTransferServiceGET /api/tasks/exportPOST /api/tasks/importTaskTransferModal.vue)。 書き出した本文がそのまま読み込みのリクエスト本文なので、テキストで保存しておけば バックアップになり、別環境への引っ越しにも使える。

  • 入口はトップの「未完了」見出しの右、「+ プロジェクト」の左に**「バックアップ」の 1 つだけ**。別セクションは設けず、ボタンも 2 つに割らない —— 一覧の下に見出しごと 置くと、めったに押さないものが常に画面の一等地を占め、見出しにボタンを 2 つ並べると 狭い画面で折り返す

  • 向きはモーダル内のタブ(既定は書き出し)。書き出したものをそのまま読み込む関係が 同じ画面で見えるようにするため。書き出しの本文はタブを往復しても取り直さない

  • 持ち出すのは未完了タスクと、所属プロジェクトの名前・リポジトリだけ。完了タスクは 「片付いたものの記録」で復元する意味が薄く、プロジェクトの説明・並び順・アーカイブ状態も 復元しない —— 戻したいのは待ち行列であって画面の状態ではない

  • プロジェクトは名前で照合し、無ければ作る(リポジトリ URL も一緒に登録する)。既にあるものは触らない。 復元のたびに手元の設定を上書きされると困るため

  • 同じプロジェクトに同じタイトルの未完了タスクがあれば飛ばす。同じファイルを二度 読んでも増えない(復元は繰り返し試すことがある)。ファイル内の重複も一度だけ作る。 照合キーはプロジェクト(id ではない)—— dryRun ではまだ作っていない プロジェクトに id が無いため。名前とタイトルは NUL で連結する(表示に使える文字で つなぐと「A」+「B C」と「A B」+「C」が同じキーになる)

  • 実行前に dryRun=true で「作るプロジェクト / 作るタスク / 飛ばすタスク」を返し、画面に出す

  • versionこれ以下なら読むで判定する。将来 2 を書き出すようになっても 古い 1 のファイルを読めるようにするため

  • 貼られた文字列の JSON 解釈だけは画面側。壊れていればサーバーに行かず手元で弾く

  • 持ち出し方はコピーとファイルの 2 つ(lib/fileTransfer.tsFileSaveButton.vue / FileLoadButton.vue)。どちらも通る本文は同じ。スマホでは貼り付けが速く、PC では 長い JSON をファイルで扱うほうが確実なので、どちらか一方に寄せない。 ルールの取り込み / まとめて表示も同じ 2 つのボタンを使う

  • 本文に触るボタンは本文の近くに置く。コピーは枠の中の右上に重ね、ファイルは 書き出しが枠のすぐ外の右上、読み込みは入力欄の見出しと同じ行の右端。 モーダルの右上は閉じる(×)だけにして、「閉じる」を下のボタン列から外している (タブで向きを変えるので、下に残るのは実行だけ)

  • ファイルの 2 つはアイコンにせず文言のボタン(.text-button)。矢印のアイコンだけでは ファイル操作だと読み取れず、コピー(定着したアイコン)と同じ扱いにできないため

  • 決まりごとの説明は見出しの右の「?」に畳む(HintTip.vue)。何が含まれるか・重複を どう扱うかは一度読めば済むので、常に出して本文の場所を取らせない

  • 読み込んだファイルは入力欄に流し込むだけで、取り込みは走らせない。 何が入るかを dryRun で見せてから実行する流れを、貼り付けたときと変えないため

ルール機能

「全 Claude Code 環境に効かせたい決まりごと」を Markdown で複数持ち、表示順に連結して 1 本の Markdown として取り出すための機能(dev.cctasks.rule, RulesView.vue)。

  • 連結は サーバー側(RuleService.combined())で行う。貼り付ける本文と GET /api/rules/combined が返す本文を常に一致させるため
  • 各ルールは ## <title> の見出しを付けて連ねる。貼り付け先でルールの境目が読めるように
  • 連結の先頭には前置き(# 共通ルール + 適用範囲の一文。RuleService.COMBINED_PREAMBLE)を 自動で付ける。CLAUDE.md の中身は普通「そのリポジトリ自身の説明」として読まれるため、 貼り先がどこであれ「作業対象のすべてのリポジトリに効く」と明示する。 ルールとして登録させないのは、貼り替えのたびに消えたり並び替えで先頭から動いたりしないため。 有効なルールが 0 件なら前置きも付けず空文字のまま
  • 前置きの直後には「規約リポジトリの扱い」ルール(RuleService.COMBINED_REPO_RULE)も 自動で付ける。規約リポジトリは各ユーザーが自分用に作る配布専用のプライベートリポジトリで、 育てる対象ではない。セッションにサブリポジトリとして含まれるぶん、放っておくとタスクの ついでに CLAUDE.md を書き換えられかねないので、「読み取り専用・自動更新禁止。更新は ユーザーが更新後の Markdown を明示的に渡したときだけ、その内容で丸ごと置き換える」と Claude Code に伝える。URL からの取得は書かない —— /api は要ログインで、 セッションから GET /api/rules/combined を叩けないため。自動付与にする理由は前置きと同じ
  • enabled=false のルールは連結に含めない。消さずに一時的に外せる
  • 並び順がそのまま連結順。並び替えはプロジェクト/タスクと同じ長押しドラッグ
  • モーダルは Markdown をレンダリングせず <pre> で素のまま出す。貼り付けるのは Markdown そのものだから。整形して見せると何をコピーしているのか分からなくなる。 本文の上に貼り先の説明(規約リポジトリの CLAUDE.md に丸ごと貼り替えるか、 CLI 版なら ~/.claude/rules/cc-tasks.md)を添えて、コピー後に迷わないようにする
  • 規約リポジトリをルール画面の一覧の下で設定できる(GET/PATCH /api/rules/settings、 値は GitHub URL か owner/repo スラッグ。PATCH は規約どおり null=変更しない・空文字=消す)。 設定すると ✳ ハンドオフの repositories常に付与される(claudeCodeUrl の第 3 引数。 repoSlug() で正規化し、プロジェクトのリポジトリと重複すれば足さない)—— Web 版のセッションに含まれたリポジトリはルート直下の CLAUDE.md が読み込まれるので、 連結ルールをそのリポジトリの CLAUDE.md に置いておけば ✳ 経由の全セッションに共通ルールが効く (プライマリでないリポジトリの .claude/rules/ は読まれる保証が無いのでルート直下に置く)。 保存先は汎用 KV の settings テーブル(行が無い = 未設定)。Spring Data JDBC は 文字列主キーの upsert と相性が悪い(save が常に UPDATE 扱い)ため、 ここだけ SettingRepository が JdbcTemplate で直接書く。フロントは stores/rules.tsloadSettings()(✳ を出すカードごとに呼ばれるので in-flight を共有して 1 リクエストにまとめる)

貼り付けからの取り込み(連結の逆)

まとめた Markdown を貼り付けてルール一覧へ戻せる(POST /api/rules/importRuleMarkdownParserRuleImportModal.vue)。貼り先に残っている 1 本の Markdown が そのままバックアップになる —— DB を失ってもルールを打ち直さずに復旧できる。

  • 区切りの解釈はサーバー側。連結をサーバーで行っているのと同じ理由で、 画面とサーバーで解釈がずれると往復しなくなる
  • 前置きと「規約リポジトリの扱い」は取り込まない。連結時に自動で付くものなので、 取り込むと貼り替えのたびに増える。捨てる見出しは COMBINED_REPO_RULE の 1 行目から 取る(同じ文字列を 2 箇所に書くと、片方だけ直したとき黙って二重取り込みになる)
  • 見出しの判定はコードフェンスの外だけ。ルール本文にはシェルの例が入ることがあり、 フェンス内の ## …(コメント)で区切ると 1 本のルールが分断される
  • ### 以下は本文として残す。本文が空の見出しは落とす
  • 取り込み方は「末尾に追加」と「全消しして入れ替え」の 2 つ。入れ替えは取り消せないので、 先に dryRun=true で取り込む見出しを返して画面に出してから実行する
  • 取り込んだルールは enabled=true。連結に載るのは有効なものだけなので、 往復させても増減しない(連結 → 取り込み → 連結 が一致することを確認済み)

配信は手でコピーする。CLI 版なら ~/.claude/rules/cc-tasks.md(マシン上の全リポジトリに効く)、 Web 版はユーザーレベル設定がクラウドセッションに引き継がれないため、 規約リポジトリ(上記)の CLAUDE.md に連結ルールを貼って ✳ 経由でセッションに含めるか、 リポジトリごとに .claude/rules/cc-tasks.md を置く。自動配信(フックや GET /api/rules.md の API キー認証エンドポイント)はまだ作っていない。必要になってから足す。

ルールは指示であって強制ではない —— CLAUDE.md と同じくシステムプロンプトの後の ユーザーメッセージとして届くので、遵守は保証されない。絶対に止めたい操作は Claude Code のフック側の仕事。