Notes
バーンダウンの理想線は「初回スコープ固定」ではなく日次スコープのスナップショットで引く
イテレーションのバーンダウンチャートを実装するとき、最初に「コミットされたスコープポイント」を1つ凍結して理想線を引くのが素直な発想だが、途中でスコープが増減すると理想線と実線がズレて意味を失う。今回は、スコープ自体も日次スナップショットに含めて、各日の理想線をその日のスコープを基準に引くように変えた。
最初の実装はこんな構造だった。
- イテレーションテーブルに
burndownScopePoints(初回コミットスコープ)を持たせる - 1日1回スナップショットを取り、
remaining_pointsだけを記録する - 理想線は
burndownScopePointsを始点として直線で引く
これだと、途中で「やっぱりこのストーリーも入れる/落とす」が起きたときに、理想線は古いスコープのまま固定されて、現実とまったく合わなくなる。新しい構造はこう。
- スナップショットテーブルに
scope_pointsカラムを追加(migration 0041) - 毎日のスナップショット時に
remaining_pointsとscope_pointsを両方記録する - 当日のスコープが残っていなければ live 集計(
Rejectedを除いた合計)でフォールバック - イテレーション側の
burndownScopePointsは捨てる
// Before: 初回スコープを凍結
await freezeScopeIfUnset(db, iterationId, committedScope);
await db.insert(snapshots).values({
iterationId,
snapshotDate: today,
remainingPoints: remaining,
});
// After: 日次でスコープも記録
await db.insert(snapshots).values({
iterationId,
snapshotDate: today,
scopePoints: scope, // ← 当日のスコープも保存
remainingPoints: remaining,
});
ポイントは、「変動しうる前提値」をスナップショットの外に置かないこと。理想線・実線・スコープ線の3本を同じ時系列ベースで描けるようになるので、後からバーンアップにも自然に拡張できる。
今回やったこと(実施済み)
- マイグレーションでスナップショットに
scope_pointsを追加 - 日次バッチ・API両方で
scope_pointsを保存 - 理想線は「各日のスコープ」を anchor に再計算
- バーンアップタブを追加するときに、同じ
scope_points列をそのまま使えた
tiptap のメンションを markdown で永続化するためのショートコード経由ラウンドトリップ
リッチテキストエディタ(tiptap)でメンション(@ユーザー)を扱うとき、保存先が markdown だと素直に往復できない。markdown には mention ノードに相当する記法がないので、エディタ → 保存 → 再表示で id とラベルが落ちる。
専用ショートコード [@ id="..." label="..."] をストレージ上のキャリアに使い、エディタ↔markdown 変換で詰め替える方式が安定した。
const MENTION_LINK = /\[@([^\]\n]+)\]\(mention:([^)]+)\)/g;
const MENTION_SHORTCODE = /\[@\s+([^\]]*)\]/g;
export function toEditorShortcodes(md: string) {
return md.replace(MENTION_LINK, (_, label, id) =>
`[@ id="${escape(id)}" label="${escape(label)}"]`
);
}
export function fromEditorShortcodes(md: string) {
return md.replace(MENTION_SHORTCODE, (full, attrs) => {
const { id, label } = parseAttrs(attrs);
return id ? `[@${escapeMd(label ?? id)}](mention:${id})` : full;
});
}
tiptap 側は Mention 拡張の renderText で @${label} を出すだけにし、独自の Suggestion ポップアップは無効化(suggestion.allow: () => false)して既存の候補UIをそのまま使う:
import { Mention } from "@tiptap/extension-mention";
export const AppMention = Mention.configure({
renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
suggestion: { char: "@", allow: () => false },
});
ポイント:
- markdown を一次ストレージにするなら、ノード固有の属性は「人間に読める識別可能なショートコード」で運ぶのが扱いやすい
- 表示・検索ヒット・他クライアントの解釈を考えて、id とラベルの両方を残しておく(id 単独だと表示崩れ、label 単独だと参照ができない)
- tiptap の Suggestion を切るかどうかは、候補UIを別実装で持っているかで決める
Tailwind v4 の dark variant をアプリ内 .dark トグルに同期する
ダークモードでだけグラデーションのフェードが白背景のままになる、というバグ。Tailwind v4 にした後、dark:from-slate-900 のような従来の指定が効いていなかった。
Tailwind v4 のデフォルトの dark バリアントは prefers-color-scheme: dark ベース。useTheme 等で <html class="dark"> を付けるアプリ内トグルを使っている場合、OS設定とアプリ内設定が一致しないとクラスベースの dark: が反応しない。
修正は CSS で dark バリアントを .dark クラス基準に上書きする1行と、グラデーションをカード背景に合わせる CSS 変数化:
@import "tailwindcss";
/* useTheme が html に付ける .dark と同期 */
@custom-variant dark (&:where(.dark, .dark *));
// Before: ライト前提の白 → ダークで残る
<div className="bg-gradient-to-t from-white to-transparent dark:from-slate-900" />
// After: カード背景の CSS 変数を使えばモード切替に追従する
<div className="bg-gradient-to-t from-[var(--color-surface)] to-transparent" />
ポイント:
- v4 で OS依存ではなくアプリ内クラスでモード切替したいなら
@custom-variant darkの上書きが必須(公式ドキュメントに記載) - 「白で消える」系のフェードは色を直書きせず、テーマ変数(
--color-surfaceなど)から引くと dark/light 両対応できる
シードスクリプトはpackage.jsonとAI向けドキュメントに両方追記する
スクロール検証用にローカルDBへ大量データを投入するシードスクリプトを作ったとき、package.json の scripts に追加するだけでなく、AIエージェント向けのガイドドキュメントにも明記した。
// package.json
"scripts": {
"seed:scroll": "bash scripts/seed-scroll-data.sh"
}
AIエージェントは package.json の scripts を参照することもあるが、「スクロール確認のためにデータを大量投入したい」という意図と使い方は、ドキュメントに書かないと推測できない。エージェント向けのガイド(例: docs/agent-guide.md)に「ローカルでスクロール検証する場合は bun run seed:scroll を実行する」のように目的ベースで書いておくと、エージェントが自律的に適切なコマンドを選べるようになる。
手順書的な書き方(「手順1: コマンドを実行する」)より、いつ使うか(「大量データが必要なスクロール検証時」)を先に書く方がエージェントの文脈マッチングに効く。
スクリプト自体も再実行可能(idempotent)に作るのがセット。INSERT OR IGNORE や prefix 付き DELETE で安全に何度でも実行できるようにしておくと、エージェントが気軽に呼べる。
メンション選択UIは表示名で見せて挿入はIDにする
コメントの@メンション機能を実装するとき、候補一覧に表示する値と実際に本文へ挿入する値を分けた。
- 候補一覧:
@田中太郎(displayName)で表示 - 挿入値:
@user_abc123(userId)を埋め込む
理由は通知システムがIDベースで動いているため。表示名は変わる可能性があるが、通知の宛先IDは変わらない。本文中のメンションも同様で、保存する値は常にIDにしておくことで、ユーザーが名前を変更しても過去コメントの通知対応が壊れない。
表示側では保存済みの @userId を読み込み時に @displayName へ変換して見せる(renderWithDisplayMentions)。編集中は候補から選んだ displayName が見えるが、エディタ内部の状態は userId を持つ。
表示・保存・通知のそれぞれで持つべきIDが異なるので、変換レイヤーをどこに置くかをあらかじめ決めておくと実装がスッキリする。
コードブロック内の@メンションは変換しない
コメント本文中の @userId を @displayName に変換するとき、コードブロック内のトークンまで置換してしまうと表示が崩れる。マークダウンをコードブロック境界で分割してから、非コード部分だけ変換するとうまくいった。
具体的には、コードブロックのパターンで本文を split し、セグメントがコードかどうかで処理を切り替える。
const CODE_SEGMENT_PATTERN = /(```[\s\S]*?```|`[^`\n]*`)/g;
function renderWithDisplayMentions(body: string, members: Member[]): string {
const idToName = new Map(members.map(m => [m.id, m.displayName]));
return body
.split(CODE_SEGMENT_PATTERN)
.map(segment =>
isCodeSegment(segment)
? segment // コードはそのまま
: replaceMentions(segment, idToName) // テキストだけ変換
)
.join("");
}
function isCodeSegment(s: string) {
return (s.startsWith("```") && s.endsWith("```")) ||
(s.startsWith("`") && s.endsWith("`"));
}
split に capture group を使うことで、コードブロック自体も配列の要素として残るのがポイント。capture group がないと区切り文字が消えてしまう。
メンションの置換時は displayName にマークダウン特殊文字が含まれうるのでエスケープも忘れずに。
自サイトへのリンクにnoreferrerを付けるとアクセス元がわからなくなる
target="_blank" のリンクに rel="noopener noreferrer" を機械的に付けがちだが、noreferrer は Referer ヘッダーを送らなくする。自サイト内のリンク(例: サービスサイトからサポートサイトへ)に付けると、遷移先のアクセス解析で「どこから来たか」が取れなくなり、direct traffic として計上されてしまう。
noopener と noreferrer は別の役割:
noopener— 遷移先ページがwindow.opener経由で元ページを操作できないようにする(セキュリティ対策)。Referer ヘッダーには影響しないnoreferrer— Referer ヘッダーを送らない。遷移先がアクセス元を知れなくなる。noopenerの効果も暗黙的に含む
現代のブラウザは target="_blank" に対して noopener 相当の挙動をデフォルトで適用するので、noopener すら省略できる場面が多い。ただし古いブラウザの互換性を考慮して rel="noopener" だけ付けるのが安全な落とし所。
使い分け:
- 外部の信頼できないリンク —
rel="noopener noreferrer"でよい。Referer を渡す必要がない - 自社・自サービスのリンク —
rel="noopener"のみ。Referer を残してアクセス解析を活かす - 内部リンク(同一ドメイン) — 通常は
target="_blank"自体が不要。使うならrel="noopener"で十分
リッチエディタのテストダブルでも aria 属性を透過する
フォーム入力を textarea からリッチテキストエディタへ差し替えたとき、コンポーネントテストの失敗原因が「機能差」ではなく「アクセシビリティ属性の欠落」になりやすい。今回もモック実装が aria-label を受け渡しておらず、getByRole('textbox', { name: ... }) 系の検証が不安定になった。
テストダブル側で ariaLabel / ariaDescribedBy / ariaInvalid をそのまま textarea へ橋渡しすると、実装を差し替えてもテストの意味を維持しやすい。
vi.mock("../components/rich-text-editor", () => ({
RichTextEditor: ({ value, onChange, ariaLabel, ariaDescribedBy, ariaInvalid, id }) => (
<textarea
id={id}
role="textbox"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid ? "true" : "false"}
/>
),
}));
今回やったこと(実施済み)
RichTextEditorのテストモックにariaLabelを追加した- 画面テスト側のモックにも同じ属性透過を追加して挙動を統一した
- 既存フォームテストを再実行し、ロール+名前ベースの探索が安定して通ることを確認した
次に試す(任意)
- 同種のモックに共通ヘルパーを用意して、属性漏れを防ぐ
一覧UIは summary と detail の取得を二段階に分離する
一覧画面で必要な最小情報だけを先に取り、行の展開や詳細遷移時だけ full detail を別クエリで取得する構成に変えた。これで初回描画時の overfetch を抑えつつ、詳細表示の責務も分離できた。
構成は次の2段階に分ける:
- 一覧クエリは常に
detail=summary— リスト描画に必要なフィールドだけを取得する。 - 詳細クエリは
id単位で遅延取得 — 行展開や詳細表示のタイミングで別取得する。
// list side
query.set("detail", "summary");
// detail side
const query = useQuery({
queryKey: itemQueryKeys.itemDetail(projectId, itemId),
queryFn: () => fetchItemDetail(projectId, itemId),
staleTime: 60_000,
});
この分離で効いたポイントは3つ:
- キャッシュ粒度が明確になる — 一覧と詳細で query key を分けると、どちらを invalidate すべきか判断しやすい。
- UIの責務と通信の責務が揃う — 「パネルは概要」「展開時に詳細」という画面の意図が、そのままデータ取得設計になる。
- 将来のフィールド追加に強い — 詳細にだけ重い関連を足しても、一覧の初期体験に影響しにくい。
今回やったこと(実施済み)
- 一覧クエリ生成ロジックに
detail=summaryを追加した - API 入力に
detailLevelを導入して usecase から repository まで伝搬した - 詳細取得フックを追加し、展開時に詳細を個別取得するようにした
- 関連テストを更新して新しい取得経路を検証した
次に試す(任意)
- summary/detail で実レスポンスサイズと描画時間を計測し、分離効果を定量化する
