From 526d615892ba9d2c78e74bed03670669bed5f9a5 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 05:40:48 +0000 Subject: [PATCH 01/15] docs: record AOJ findings for JAG regional 2013 A-C in task title index plan Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 6 ++--- .../plan-pr3.md | 24 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 8f124017c..d0d805580 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -59,12 +59,12 @@ PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの | JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | | JAG 模擬地区 2008 | J Colony Maintenance | -一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24): +一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24。模擬地区 2013 は 2026-10-28): - **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない** -- **JAG 模擬地区 2013 の A Broken Audio Signal / B Restore Calculation / C SIRO Challenge**: スプレッドシートではリンク付き(未収録の表記が無い)で、リンク先は AOJ 2565 / 2566 / 2567。ただし AOJ 側で title が見つからない。AOJ の模擬地区 2013 の一覧(本番の 7 問、2568〜2574)にも無い +- **JAG 模擬地区 2013 の A Broken Audio Signal / B Restore Calculation / C SIRO Challenge**: スプレッドシートではリンク付き(未収録の表記が無い)で、リンク先は AOJ 2565 / 2566 / 2567。AOJ の模擬地区 2013 の一覧(`challenges/cl/jag/regional`、本番の 7 問、2568〜2574)には無いが、問題一覧 API に同じ ID・同じ問題名で載っている。コンテスト一覧から外れているだけで、1123・3643 と同じ種類。A〜J が 2565〜2574 の連番になる -どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ へのアクセスは、この環境からは拒否された(`judgeapi.u-aizu.ac.jp` が HTTP 410)。 +どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ のドメインは `https://onlinejudge.u-aizu.ac.jp/` に変わった(旧 API の `judgeapi.u-aizu.ac.jp` は HTTP 410)。API の向き先(`/api/`)と開発コンテナの許可リストは PR1.5 で新ドメインに更新済みなので、PR3 の 3-0 の確認は新ドメインで行う。 ## DB に無いコンテスト diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index 973b0bb17..6ef3a416c 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -13,22 +13,22 @@ ## 分かっている欠落 -| 問題 | 分かっていること(2026-10-24 にメンテナが確認) | 種類 | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | -| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | -| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ | 前者 | -| JAG 模擬地区 2013 の A〜C | スプレッドシートのリンク先は AOJ 2565 / 2566 / 2567。ただし AOJ 側で title が見つからない | 未確定(問題として使えるか) | -| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 未確認 | 後者の候補 | +| 問題 | 分かっていること | 種類 | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | +| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ(2026-10-24 にメンテナが確認) | 前者 | +| JAG 模擬地区 2013 の A〜C | AOJ の問題一覧 API に 2565 / 2566 / 2567 として存在し、問題名がスプレッドシートと一致する。コンテスト一覧(`challenges/cl/jag/regional`)に無いだけで、D〜J(2568〜2574)と ID が連番(2026-10-28 にメンテナが確認) | 前者 | +| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 未確認 | 後者の候補 | -3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。3-0 では 21 問すべてを AOJ の問題番号で確かめる。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 +3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。2565〜2567 はコンテスト一覧に無いが、問題一覧 API には載っていた。3-0 では 21 問すべてを問題一覧 API で問題名から確かめる。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 ## タスク -| # | 内容 | -| --- | ---------------------------------------------------------------------------------------------------------------------- | -| 3-0 | 「AOJ 未収録」21 問と 2565〜2567 について、AOJ に問題番号で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | -| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT) | -| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | +| # | 内容 | +| --- | --------------------------------------------------------------------------------------------------------------------- | +| 3-0 | 「AOJ 未収録」21 問について、AOJ の問題一覧 API に問題名で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | +| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT)。判明分は 1123、3643、2565〜2567 | +| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | 3-2 で決めること: From bce4a7e465afe717cc19ff9f85430abd61976728 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 05:58:32 +0000 Subject: [PATCH 02/15] docs: record completed AOJ missing-task survey in task title index plan Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 8 ++--- .../plan-pr3.md | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index d0d805580..4127b3b4b 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -46,7 +46,7 @@ PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの ## 欠落の一覧(PR3) -スプレッドシートで「AOJ 未収録」と書かれている問題。22 問(合宿系 18、模擬地区 4)。 +スプレッドシートで「AOJ 未収録」と書かれている問題。22 問(合宿系 18、模擬地区 4)。うち Interactive String Guessing は AOJ 3643 にあると確認済み(下記)。残る 21 問は、問題一覧 API で問題名を部分一致で検索しても ID が見つからなかった(2026-10-28 にメンテナが確認。表記ゆれのありうる Wireing / "Shortest" / Geometry?! も部分一致で確認)。 | コンテスト | 記号と問題名 | | -------------------- | -------------------------------------------------------------------------------------------------------------------- | @@ -55,16 +55,16 @@ PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの | JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | | JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | | JAG 冬合宿 2007 Day3 | E Networking Company | -| JAG 夏合宿 2024 Day3 | G Interactive String Guessing | +| JAG 夏合宿 2024 Day3 | G Interactive String Guessing(AOJ 3643。確認済み) | | JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | | JAG 模擬地区 2008 | J Colony Maintenance | 一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24。模擬地区 2013 は 2026-10-28): -- **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない** +- **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない**。問題一覧 API にも載っているが、`available` が 4(2565〜2567 は 1)で、正解者・提出がともに 0。AOJ への登録日(created at)が 2026-10-10 なので、追加されたばかりで公開前の状態と考えられる(2026-10-28 に確認。`available` の値の意味は未確認)。スプレッドシートの「未収録」は記入時点では正しく、その後に AOJ に追加されたとみられる - **JAG 模擬地区 2013 の A Broken Audio Signal / B Restore Calculation / C SIRO Challenge**: スプレッドシートではリンク付き(未収録の表記が無い)で、リンク先は AOJ 2565 / 2566 / 2567。AOJ の模擬地区 2013 の一覧(`challenges/cl/jag/regional`、本番の 7 問、2568〜2574)には無いが、問題一覧 API に同じ ID・同じ問題名で載っている。コンテスト一覧から外れているだけで、1123・3643 と同じ種類。A〜J が 2565〜2574 の連番になる -どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ のドメインは `https://onlinejudge.u-aizu.ac.jp/` に変わった(旧 API の `judgeapi.u-aizu.ac.jp` は HTTP 410)。API の向き先(`/api/`)と開発コンテナの許可リストは PR1.5 で新ドメインに更新済みなので、PR3 の 3-0 の確認は新ドメインで行う。 +どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ のドメインは `https://onlinejudge.u-aizu.ac.jp/` に変わった(旧 API の `judgeapi.u-aizu.ac.jp` は HTTP 410)。API の向き先(`/api/`)と開発コンテナの許可リストは PR1.5 で新ドメインに更新済み。 ## DB に無いコンテスト diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index 6ef3a416c..505cf7ec5 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -2,7 +2,7 @@ 前提と共通の決定は [plan.md](./plan.md)。欠落の一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 -**状態**: 未着手。着手前に調査(3-0)を置く。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 +**状態**: 調査(3-0)は完了(2026-10-28)。3-1 / 3-2 は未着手。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 ## 欠落の 2 種類 @@ -13,22 +13,26 @@ ## 分かっている欠落 -| 問題 | 分かっていること | 種類 | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | -| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ(2026-10-24 にメンテナが確認) | 前者 | -| JAG 模擬地区 2013 の A〜C | AOJ の問題一覧 API に 2565 / 2566 / 2567 として存在し、問題名がスプレッドシートと一致する。コンテスト一覧(`challenges/cl/jag/regional`)に無いだけで、D〜J(2568〜2574)と ID が連番(2026-10-28 にメンテナが確認) | 前者 | -| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 未確認 | 後者の候補 | +| 問題 | 分かっていること | 種類 | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | +| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ(2026-10-24 にメンテナが確認)。2026-10-10 に AOJ に追加されたばかりで、問題一覧 API では `available` が 4(2565〜2567 は 1)、提出が 0(2026-10-28 に確認) | 前者(公開前の可能性) | +| JAG 模擬地区 2013 の A〜C | AOJ の問題一覧 API に 2565 / 2566 / 2567 として存在し、問題名がスプレッドシートと一致する。コンテスト一覧(`challenges/cl/jag/regional`)に無いだけで、D〜J(2568〜2574)と ID が連番(2026-10-28 にメンテナが確認) | 前者 | +| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 問題一覧 API で問題名を部分一致で検索したが、ID が見つからない(2026-10-28 にメンテナが確認) | 後者 | -3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。2565〜2567 はコンテスト一覧に無いが、問題一覧 API には載っていた。3-0 では 21 問すべてを問題一覧 API で問題名から確かめる。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 +3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。2565〜2567 と 3643 はどちらもコンテスト一覧に無いが、問題一覧 API には載っていた。3-0 では 21 問すべてを問題一覧 API で問題名から確かめ、いずれも見つからなかった。AOJ は過去の問題を後から追加する(3643 は 2026-10-10)ので、この結果は 2026-10-28 時点のもの。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 ## タスク -| # | 内容 | -| --- | --------------------------------------------------------------------------------------------------------------------- | -| 3-0 | 「AOJ 未収録」21 問について、AOJ の問題一覧 API に問題名で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | -| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT)。判明分は 1123、3643、2565〜2567 | -| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | +| # | 内容 | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 3-0 | 「AOJ 未収録」21 問について、AOJ の問題一覧 API に問題名で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する。見つかった問題は `available` と提出数も記録する。**完了**: 前者 5 問(1123、3643、2565〜2567)、後者 21 問。後者はすべて 2005〜2008 年のコンテストで、冬合宿 2006 の Day2 / Day3 に 13 問が集中する | +| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT)。判明分は 1123、3643、2565〜2567 | +| 3-2 | AOJ に無い問題(21 問)のダミー Task レコードの設計 → 実装 | + +3-1 で決めること: + +- **`available` が 1 でない問題の扱い**。3643 は 2026-10-10 に AOJ に追加されたばかりで、`available` が 4、提出が 0。公開前の状態なら、AOJ のコンテスト一覧に入った後で管理画面の取り込みで入れられるかもしれない。手で INSERT するか、一覧に入るのを待つかを決める。先に `available` の値の意味と、判明分(1123 を含む)の値を確かめる 3-2 で決めること: From dba8a514624c1c574fa9f0226aa910163753c9e4 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 06:26:39 +0000 Subject: [PATCH 03/15] docs: plan jag-icpc.org links for tasks missing from AOJ Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 30 ++++++++++++------- .../plan-pr3.md | 11 ++++++- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 4127b3b4b..58a15368e 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -48,16 +48,26 @@ PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの スプレッドシートで「AOJ 未収録」と書かれている問題。22 問(合宿系 18、模擬地区 4)。うち Interactive String Guessing は AOJ 3643 にあると確認済み(下記)。残る 21 問は、問題一覧 API で問題名を部分一致で検索しても ID が見つからなかった(2026-10-28 にメンテナが確認。表記ゆれのありうる Wireing / "Shortest" / Geometry?! も部分一致で確認)。 -| コンテスト | 記号と問題名 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | -| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | -| JAG 夏合宿 2006 Day3 | A Rolling Cubes | -| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | -| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | -| JAG 冬合宿 2007 Day3 | E Networking Company | -| JAG 夏合宿 2024 Day3 | G Interactive String Guessing(AOJ 3643。確認済み) | -| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | -| JAG 模擬地区 2008 | J Colony Maintenance | +| コンテスト | 記号と問題名 | jag-icpc.org の年度ページ | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | [2006/Practice/夏合宿][jag-2006-summer] | +| JAG 夏合宿 2006 Day3 | A Rolling Cubes | [2006/Practice/夏合宿][jag-2006-summer] | +| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | [2006/Practice/冬合宿][jag-2006-winter] | +| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | [2006/Practice/冬合宿][jag-2006-winter] | +| JAG 冬合宿 2007 Day3 | E Networking Company | [2007/Practice/冬合宿][jag-2007-winter] | +| JAG 夏合宿 2024 Day3 | G Interactive String Guessing(AOJ 3643。確認済み) | — | +| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | [2005/Practice/模擬地区予選][jag-2005-regional] | +| JAG 模擬地区 2008 | J Colony Maintenance | [2008/Practice/模擬地区予選][jag-2008-regional] | + +[jag-2006-summer]: https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF +[jag-2006-winter]: https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF +[jag-2007-winter]: https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF +[jag-2005-regional]: https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 +[jag-2008-regional]: https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 + +AOJ に無い 21 問は、すべてのコンテストに JAG のサイト(jag-icpc.org、スプレッドシートの情報源の一つ)の年度ページがあり、5 ページとも問題文へのリンクがある(2026-10-28 にメンテナが確認)。2006 年夏合宿では、問題文は日ごとの PDF で、ジャッジデータも収録されている。 + +サイトは URL の形式(`?plugin=attach&refer=...&openfile=...`)から PukiWiki とみられる。構成は `年度/Practice/大会区分/問題文とデータセット/`(その下に PDF と注釈)。`問題文とデータセット` と同じ階層に講評などの別のページもある。添付ファイル名は揃っていない(2006 年夏合宿は `day1.pdf` など日ごと、2008 年模擬地区予選は `problems.pdf`)。2008 年の PDF の URL は `https://jag-icpc.org/?plugin=attach&refer=2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8%2F%E5%95%8F%E9%A1%8C%E6%96%87%E3%81%A8%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BB%E3%83%83%E3%83%88&openfile=problems.pdf`(2026-10-28 にメンテナが共有)。 一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24。模擬地区 2013 は 2026-10-28): diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index 505cf7ec5..f48012802 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -37,5 +37,14 @@ 3-2 で決めること: - **`task_id` の採番**。AOJ に無い問題には ID が存在しないため、衝突しない採番規則が要る -- **提出リンクの扱い**。リンク先が存在しないので無効化するか、表示自体を変えるか +- **問題へのリンク先**: JAG のサイト(jag-icpc.org)の大会区分のページ(例: `2008/Practice/模擬地区予選`)にする。21 問のコンテストすべてにあり、5 ページで済む([一覧と構成](./investigation.md#欠落の一覧pr3)) + - 理由: 提出先が無いので、自分で採点するには問題文だけでなくジャッジデータと講評も要る。大会区分のページからはどれにも下りられる。逆に、深いページに着地したユーザーは上の階層にほぼ戻らない(PDF には戻る手段すら無い)ので、入口のページが見つけられる範囲を決める。階層が浅いほど、下位ページ名や添付ファイル名の変更でリンクが切れる箇所も、URL 中の日本語も減る + - 不採用: PDF への直リンク(問題文までは 1 クリックだが講評に辿れない。添付ファイル名が `day1.pdf` / `problems.pdf` と揃っていない)。`問題文とデータセット` のページ(講評に辿れない) + - リンク先は map の値にすぎないので、後から深くするのは定数の差し替えで済む +- **リンク先の持ち方**: `getTaskUrl()`(`src/lib/utils/task.ts`)の先頭で、ダミーの `task_id` をキーにした map を引き、該当すれば jag-icpc.org の URL を返す。問題へのリンクはすべて `getTaskUrl()` を通る(呼び出し元 9 ファイル)ので、1 か所で全画面に効く + - map は `{ url, taskIds }` をページごとに並べて組み立て、各 URL を 1 回だけ書く + - URL はブラウザで開けたものをパーセントエンコードのまま貼る(ソースは ASCII のみ)。部品から組み立てない。そのまま返すことを単体テストで固定し、二重エンコードや書き換えを防ぐ + - ダミーは DB、map はコードにあるので、map の漏れは存在しない AOJ のページへのリンクになる(今の `getTaskUrl()` は JAG の contest_id なら AOJ の `/problems/{task_id}` を返す)。ダミーと分かる `task_id` の接頭辞(採番で決める)と、seed のダミーが全件 map にあることのテストで防ぐ + - 不採用: Task に `url` 列を足す(マイグレーションと呼び出し元 9 ファイルへの波及があり、クロスレビューの対象にもなる。21 件には重い。他サイトへの対応時に再検討)。URL ジェネレータを足す(`canHandle` が contest_id しか受け取らず、インターフェースの変更が要る) +- **ダミーのリンクの見た目**: 他の問題と同じ見た目だと、AOJ に提出するつもりで開いて jag-icpc.org に飛ぶ。「問題文のみ」などの表示を足すか - **投票 / タグ / 問題集への登場可否**。FK が `task_id` 参照であるため、ダミーを入れると各機能に波及する From d685042eef2a25397a55519fb200fb5e2926a751 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 07:35:52 +0000 Subject: [PATCH 04/15] docs: add PR3 dummy task design and impact analysis Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 172 +++++++++++++++++- .../plan-pr3.md | 27 ++- 2 files changed, 175 insertions(+), 24 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 58a15368e..1eae0e1d8 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -2,14 +2,15 @@ [plan.md](./plan.md) と PR ごとの計画(`plan-pr*.md`)の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** -| 章 | 内容 | 主に使う PR | -| ----------------------------------------------- | --------------------------------------------------- | ------------------- | -| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、欠落、表示順 | PR2 / 2.1 / 2.5 / 3 | -| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | -| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | -| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | -| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | -| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | +| 章 | 内容 | 主に使う PR | +| ----------------------------------------------- | ------------------------------------------------------------------ | ------------------- | +| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、欠落、表示順 | PR2 / 2.1 / 2.5 / 3 | +| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | +| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | +| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | +| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | +| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | +| [7](#7-pr3-のダミーの設計と影響範囲) | AOJ に無い問題の `task_id`、リンク、出典のラベル、見た目、影響範囲 | PR3 | PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [done.md](./done.md)。 @@ -479,3 +480,158 @@ Task を削除するコードが存在しないため、関連テーブルが孤 6. **PR を分けた後も、移行 SQL の範囲を 1,099 件のままにしていた**(2026-10-21 に発見)— `contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` は合宿系も含むため、PR1 で 467 件を余計に更新するところだった。 _教訓: 範囲を分割したら、範囲を表す条件をすべて追従させる。分類(`contest_type`)と分割の単位が一致しているとは限らない。_ 7. **欠落した問題を「AOJ に存在しない(ID も無い)」と決めつけていた**(2026-10-21 に発見)— ICPC 国内予選 2002 の E は AOJ に問題 1123 として存在し、コンテスト一覧(取り込み元)から外れているだけだった。PR3 でダミーを作るという前提は、この問題には当てはまらない。 _教訓: 「一覧に無い」と「存在しない」は別。データの取得元の範囲を、対象が実在する範囲と取り違えない(1・4 と同じ型)。_ + +--- + +# 7. PR3 のダミーの設計と影響範囲 + +[plan-pr3.md](./plan-pr3.md) の 3-2 の根拠。対象は AOJ に無い 21 問([欠落の一覧](#欠落の一覧pr3))。コードの確認は 2026-10-28 時点。 + +## `task_id` の形式 + +`NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。 + +- 接頭辞は、コードが分岐に使う性質(採点の仕組みが無い)をそのまま表す。ID だけで欠落の問題と分かる +- 数字でも小文字始まりでもないので、AOJ(数字)とも AtCoder(`abc300_a` など)とも構造上ぶつからない +- 記号を小文字にするのは AtCoder の形式(`abc300_a`、`jag2018summer_day2_a`)に合わせるため +- `contest_id` と記号の組は一意なので、ID も一意になる +- 英数字・`-`・`_` だけなので、`/problems/{task_id}` にそのまま入る +- 判定は `startsWith('NoJudge-')` で足りる + +採らなかった案: + +| 案 | 採らない理由 | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 数字(100000 番台など) | 既存のコードは壊れないが、AOJ の本物の ID と見分けられない。数字の範囲は AOJ が決める領域(通常の問題は 40xx 番台まで、AOJ 自身のダミーに 9900〜9995)で、衝突の心配が残る。map の漏れが、本物らしく見えて開けない AOJ のリンク(`/problems/100000`)になる | +| 50xx 番台 | AOJ の通常の採番がいずれ届く | +| `Missing-` | このノートの「欠落」は、AOJ にあるがコンテスト一覧に無い問題(1123 など)も含むので、意味がずれる。欠けているのは採点の仕組みだけで問題文はあるのに、中身の無い仮のレコードやエラーページにも読める。分岐の理由ではなく、取り込みから漏れた経緯を表す | +| `jag-icpc_` など出所を表す接頭辞 | 分岐の理由(採点が無い)ではなく、問題文の置き場所を表す | + +### AOJ に後から追加された場合 + +3643 のように、AOJ は過去の問題を後から追加することがある。そのときは AOJ の ID に移す(どの採番でも必要になる)。 + +- `task_id` を参照する外部キーは 6 つとも `ON UPDATE CASCADE`(taskanswer / tasktag / workbooktask / votegrade / votedgradecounter / votedgradestatistics、`prisma/migrations` で確認)。`task_id` を UPDATE すれば、回答・投票・タグ・問題集のデータはそのまま付いてくる +- あわせて map のエントリを消す +- 出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る +- 移し忘れても、`NoJudge-` が残っていれば古いと一目で分かる + +## 影響範囲 + +コードの変更は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ。ほかは Task のレコードを足せば動く。 + +| 機能・処理 | 何で決まるか | ダミーへの影響 | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| コンテスト別の表 | `contest_id` で絞り込み、`task_table_index` の順に列を並べ(`sortAojHeaderIds`)、`table[contest_id][task_table_index]` に置く(`generateTable`)。`task_id` は使わない | 7 コンテストとも `JagRegionalProvider` の entry が既にある(`aoj_jag_regional_entries.ts`)。記号は空いている位置にちょうど収まる(下表)。変更なし | +| セルの表示名 | AOJ の表は `task_table_index` を付けて「A. Monkey Business」と出す | 変更なし | +| 問題へのリンク | すべて `getTaskUrl()` を通る(呼び出し元 9 ファイル)。AOJ の URL を組み立てる箇所はほかに無い | `NoJudgeGenerator` を足す(コードの変更 1、[下記](#リンク先の持ち方)) | +| 出典のラベル | 汎用関数 `addContestNameToTaskIndex(contestId, taskTableIndex)`(`src/lib/contests/utils/task_index_label.ts`)。`contest_id` だけを見て、AOJ 系のコンテストなら `(AOJ)` を付ける。呼び出し元は 6 ファイル(7 か所): 問題一覧(`TaskList` / `TaskListSorted`)、問題集(`WorkBookTasksTable`、`workbooks/[slug]`)、管理画面(`TaskForm`、`TaskGradeTable`) | そのままだと誤って `(AOJ)` と出る。`taskId` を足し、ダミーなら `(No Judge)` にする(コードの変更 2、[下記](#出典のラベル))。コンテスト別の表のセルには出典のラベルが出ないので、表は変わらない | +| 投票・タグ・問題集・回答 | `task_id` への外部キー(6 テーブル) | Task があればそのまま動く | +| 問題の詳細ページ | `/problems/{task_id}` | 変更なし | +| 問題の一覧の並び | `task_id` の文字列の降順(`src/lib/services/tasks.ts`、`vote_statistics.ts`) | 位置が変わるだけ | +| 入力チェック | zod は `task_id` が空でない文字列かだけを見る | 変更なし | +| `task_id` を数値として読む箇所 | 無い(`parseInt` / `Number` などは使われていない) | 変更なし | +| `task_id` を分解する箇所 | `getCourseName()` の `split('_')` だけ。AOJ の API の応答にしか使わない | 変更なし | +| 解答状況 | ユーザーがメニューで設定する。AOJ から提出結果を取り込む処理は無い | ジャッジデータで自分で確かめて AC にできる | +| 管理画面の AOJ 取り込み | Task を削除しない([5 章](#5-task-テーブルへの書き込み経路の全数調査)) | 取り込みをやり直してもダミーは消えない | + +コンテスト別の表での記号: + +| コンテスト | 今ある記号 | ダミーで足す記号 | 合わせると | +| ------------------ | ---------------- | ------------------- | ---------- | +| JAGSummer2006-day1 | B | A, C | A〜C | +| JAGSummer2006-day3 | B〜G | A | A〜G | +| JAGWinter2006-day2 | D, F | A, B, C, E, G, H, I | A〜I | +| JAGWinter2006-day3 | A, D, G, H | B, C, E, F, I, J | A〜J | +| JAGWinter2007-day3 | E 以外の A〜J | E | A〜J | +| JAGRegional2005 | A, B, D, E, G, I | C, F, H | A〜I | +| JAGRegional2008 | A〜I | J | A〜J | + +レコードの入れ方: + +- seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から `AOJ_JAG` に決める +- staging / 本番: Supabase の CSV や生の SQL は Prisma の既定値を使わないので、`id`・`contest_type`・`created_at`・`updated_at` を明示する(`docs/guides/agent-rules/prisma-db.md`)。既存の合宿系の CSV では `id` を `sha256(contest_id + title)` にしている([5 章](#id-の採番規則が経路ごとに違う)) + +## リンク先 + +jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`)にする。21 問のコンテストすべてにあり、5 ページで済む([一覧と構成](#欠落の一覧pr3))。 + +- 提出先が無いので、自分で採点するには問題文だけでなく、ジャッジデータと講評も要る。大会区分のページからは、どれにも下りられる +- 深いページに着地したユーザーは、上の階層にほぼ戻らない(PDF には戻る手段すら無い)。入口のページが、ユーザーの見つけられる範囲を決める +- 階層が浅いほど、下位ページ名や添付ファイル名の変更でリンクが切れる箇所も、URL 中の日本語も減る +- リンク先は map の値にすぎないので、後から深くするのは定数の差し替えで済む + +採らなかった案: + +| 案 | 採らない理由 | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| PDF への直リンク | 問題文までは 1 クリックだが、講評に辿れない。添付ファイル名が `day1.pdf` / `problems.pdf` と揃っておらず、切れる要因が増える | +| `問題文とデータセット` のページ | 講評に辿れない | + +## リンク先の持ち方 + +`getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータの並びに `NoJudgeGenerator` を足す。問題へのリンクはすべて `getTaskUrl()` を通るので、1 か所で全画面に効く。 + +```typescript +class NoJudgeGenerator implements UrlGenerator { + canHandle(_contestId: string, taskId: string): boolean { + return isNoJudgeTask(taskId); + } + + generateUrl(_contestId: string, taskId: string): string { + return NO_JUDGE_TASK_URLS.get(taskId) ?? ''; + } +} + +// NoJudge must precede AOJ: dummies share JAG contest_ids. +const urlGenerators: UrlGenerators = [ + new NoJudgeGenerator(), + new AojGenerator(), + new AtCoderGenerator(), +]; +``` + +- 既存の設計(ジェネレータを並べ、最初に `canHandle` が真になったものを使う)に沿う。`getTaskUrl()` の本体は変わらず、`task.ts` の TODO(Codeforces・yukicoder・BOJ)も同じ形で足せる +- `UrlGenerator.canHandle`(`src/lib/types/url.ts`)に `taskId` を足す。使うのは `task.ts` だけで、既存の `AojGenerator` / `AtCoderGenerator` は引数が少ないままでも型を満たすので、書き換えは要らない。変更は型定義と TSDoc、`getTaskUrl()` の呼び出しの 1 行 +- **並び順に意味がある**。ダミーは JAG の `contest_id` を使うので、`NoJudgeGenerator` を `AojGenerator` より前に置かないと AOJ 側が先に受け取る。今の「AtCoder を最後に置く」と同じ種類の制約で、コメントとテスト(ダミーの `task_id` で jag-icpc.org の URL が返ること)で固定する +- 判定は出典のラベルと共通の `isNoJudgeTask()`(`startsWith('NoJudge-')`)。URL と出典のラベルで、ダミーかどうかの基準が 1 つになる +- URL は問題ごとではなくページごとに決まる(21 問に対して 5 ページ)。map は `{ url, taskIds }` をページごとに並べて組み立て、各 URL を 1 回だけ書く +- URL はブラウザで開けたものを、パーセントエンコードのまま貼る(ソースは ASCII だけになる)。部品から組み立てない(ファイル名やページ名の規則を推測すると、外れたときに黙って間違ったリンクになる)。そのまま返すことを単体テストで固定し、二重エンコードや読みやすくするための書き換えを防ぐ +- ダミーは DB、map はコードにあるので、map に漏れがあり得る。漏れたときは空文字を返し、AOJ のジェネレータには回らない(本物らしく見えて開けない AOJ のリンクにはならない)。seed の `NoJudge-` が全件 map にあることをテストで確かめて防ぐ +- 外部リンクを CI で定期的に確かめることはしない(外部サイトの状態でテストが落ちるようになる) + +採らなかった案: + +| 案 | 採らない理由 | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Task に `url` 列を足す | URL がレコードと一緒に管理できるのでデータの持ち方としては素直だが、マイグレーションが要り、`getTaskUrl()` の呼び出し元 9 ファイルに URL を渡す変更が波及する。クロスレビューの対象にもなる。21 件には重い。他サイト(`task.ts` の TODO にある Codeforces など)に対応するときに再検討する | +| `getTaskUrl()` の先頭で map を引く | コードは 2〜3 行で済むが、`getTaskUrl()` に特別扱いが入り、例外が増えるたびに先頭の分岐が増える。判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が 2 つに割れる。map に漏れがあると AOJ のジェネレータに回り、開けない AOJ のリンクになる。当初はこちらを採り、ジェネレータ案は「`canHandle` のインターフェースの変更が要る」として退けたが、実際の変更は型定義 1 行で済むと分かり、入れ替えた(2026-10-28) | + +## 出典のラベル + +ダミーの出典は `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`。2026-10-28 に決定)。 + +- 括弧の中は「どこで解けるか(どこで採点されるか)」を表す。採点の無い問題に `(AOJ)` と出すのは誤り +- `task_id` の接頭辞 `NoJudge-` と言葉がそろう +- 問題一覧や問題集では、アイコンを足さずに文字だけで、採点が無いことを伝えられる +- `addContestNameToTaskIndex()` は `contest_id` と記号しか受け取らない。ダミーは本物の AOJ の問題と同じ `contest_id` を使うので、`contest_id` では見分けられず、`taskId` を足す + - 省略できない引数にする。省略できる引数にすると、渡し忘れた呼び出し元が黙って `(AOJ)` のままになる。必須なら、型チェックが 7 か所の漏れをすべて見つける。呼び出し元はどこも `task_id` を持っている + - ダミーの判定は、URL のジェネレータと共通の `isNoJudgeTask()`(`startsWith('NoJudge-')`)を使う + - 括弧は今と同じ全角。テストのデータ(`src/lib/contests/fixtures/contest_name_and_task_index.ts`)にダミーの場合を足す + +## アイコンもツールチップも入れない理由 + +アイコンもツールチップも入れない(2026-10-28 に決定)。採点が無いことは、出典のラベルの `(No Judge)` で文字として伝える。コンテスト別の表のセルには出典のラベルが出ないので、表の見た目は変わらない。 + +- 表のセルは [難易度バッジ] [問題名(長いと省略)] [状態のメニュー] で、AOJ の表は 1 行に最大 6 列。問題名はもう省略されていて、アイコンを足すとさらに削られる。外部リンクのアイコンも、表では消している(`ExternalLinkWrapper` の `iconSize={0}`) +- ホバーで出る jag-icpc.org の URL だけでは気づきにくい。スマホにはホバーが無く、画面の隅の URL を見る人は少なく、ドメインが違っても提出先が無いことまでは読み取りにくい +- それでも、引っかかったときの損は小さい。リンクは新しいタブで開き、開けば提出フォームの無い Wiki だと分かる。解答状況はユーザーが自分で設定するので、ジャッジデータで自分で確かめて AC にできる + +採らなかった案: + +| 案 | 採らない理由 | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| セルにアイコンを足す | セルの幅が足りない | +| リンクに `title` 属性のツールチップ | 幅は使わないが、スマホでは出ない。`ExternalLinkWrapper` に引数を足す必要がある。知らせる仕組みが必要になったら、最初に検討する | +| 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので、意味が混ざる。どの問題が対象かも分からない | +| 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index f48012802..cefe5b08a 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -2,7 +2,7 @@ 前提と共通の決定は [plan.md](./plan.md)。欠落の一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 -**状態**: 調査(3-0)は完了(2026-10-28)。3-1 / 3-2 は未着手。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 +**状態**: 調査(3-0)は完了、3-2 は設計まで決定(いずれも 2026-10-28)。3-1 と 3-2 の実装は未着手。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 ## 欠落の 2 種類 @@ -28,23 +28,18 @@ | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 3-0 | 「AOJ 未収録」21 問について、AOJ の問題一覧 API に問題名で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する。見つかった問題は `available` と提出数も記録する。**完了**: 前者 5 問(1123、3643、2565〜2567)、後者 21 問。後者はすべて 2005〜2008 年のコンテストで、冬合宿 2006 の Day2 / Day3 に 13 問が集中する | | 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT)。判明分は 1123、3643、2565〜2567 | -| 3-2 | AOJ に無い問題(21 問)のダミー Task レコードの設計 → 実装 | +| 3-2 | AOJ に無い問題(21 問)のダミー Task レコードの設計 → 実装。設計は決定(下記) | 3-1 で決めること: - **`available` が 1 でない問題の扱い**。3643 は 2026-10-10 に AOJ に追加されたばかりで、`available` が 4、提出が 0。公開前の状態なら、AOJ のコンテスト一覧に入った後で管理画面の取り込みで入れられるかもしれない。手で INSERT するか、一覧に入るのを待つかを決める。先に `available` の値の意味と、判明分(1123 を含む)の値を確かめる -3-2 で決めること: - -- **`task_id` の採番**。AOJ に無い問題には ID が存在しないため、衝突しない採番規則が要る -- **問題へのリンク先**: JAG のサイト(jag-icpc.org)の大会区分のページ(例: `2008/Practice/模擬地区予選`)にする。21 問のコンテストすべてにあり、5 ページで済む([一覧と構成](./investigation.md#欠落の一覧pr3)) - - 理由: 提出先が無いので、自分で採点するには問題文だけでなくジャッジデータと講評も要る。大会区分のページからはどれにも下りられる。逆に、深いページに着地したユーザーは上の階層にほぼ戻らない(PDF には戻る手段すら無い)ので、入口のページが見つけられる範囲を決める。階層が浅いほど、下位ページ名や添付ファイル名の変更でリンクが切れる箇所も、URL 中の日本語も減る - - 不採用: PDF への直リンク(問題文までは 1 クリックだが講評に辿れない。添付ファイル名が `day1.pdf` / `problems.pdf` と揃っていない)。`問題文とデータセット` のページ(講評に辿れない) - - リンク先は map の値にすぎないので、後から深くするのは定数の差し替えで済む -- **リンク先の持ち方**: `getTaskUrl()`(`src/lib/utils/task.ts`)の先頭で、ダミーの `task_id` をキーにした map を引き、該当すれば jag-icpc.org の URL を返す。問題へのリンクはすべて `getTaskUrl()` を通る(呼び出し元 9 ファイル)ので、1 か所で全画面に効く - - map は `{ url, taskIds }` をページごとに並べて組み立て、各 URL を 1 回だけ書く - - URL はブラウザで開けたものをパーセントエンコードのまま貼る(ソースは ASCII のみ)。部品から組み立てない。そのまま返すことを単体テストで固定し、二重エンコードや書き換えを防ぐ - - ダミーは DB、map はコードにあるので、map の漏れは存在しない AOJ のページへのリンクになる(今の `getTaskUrl()` は JAG の contest_id なら AOJ の `/problems/{task_id}` を返す)。ダミーと分かる `task_id` の接頭辞(採番で決める)と、seed のダミーが全件 map にあることのテストで防ぐ - - 不採用: Task に `url` 列を足す(マイグレーションと呼び出し元 9 ファイルへの波及があり、クロスレビューの対象にもなる。21 件には重い。他サイトへの対応時に再検討)。URL ジェネレータを足す(`canHandle` が contest_id しか受け取らず、インターフェースの変更が要る) -- **ダミーのリンクの見た目**: 他の問題と同じ見た目だと、AOJ に提出するつもりで開いて jag-icpc.org に飛ぶ。「問題文のみ」などの表示を足すか -- **投票 / タグ / 問題集への登場可否**。FK が `task_id` 参照であるため、ダミーを入れると各機能に波及する +3-2 で決めたこと(2026-10-28)。根拠、採らなかった案の詳細、影響範囲は [investigation.md の 7 章](./investigation.md#7-pr3-のダミーの設計と影響範囲)。 + +- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。不採用: 数字(100000 番台、50xx 番台)、`Missing-` などの接頭辞 +- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる。不採用: PDF への直リンク、`問題文とデータセット` のページ +- **リンク先の持ち方**: `getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。`UrlGenerator.canHandle` に `taskId` を足す(既存の 2 つの実装は書き換え不要)。判定は出典のラベルと共通の `isNoJudgeTask()`、URL は `task_id` をキーにした map から引く。map は `{ url, taskIds }` をページごとに並べて組み立てる。URL はブラウザで開けたものをパーセントエンコードのまま貼る。テストで、URL をそのまま返すこと、ジェネレータの並び順、seed の `NoJudge-` が全件 map にあることを固定する。不採用: `getTaskUrl()` の先頭で map を引く、Task に `url` 列を足す +- **出典のラベル**: ダミーは `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。汎用関数 `addContestNameToTaskIndex()`(`src/lib/contests/utils/task_index_label.ts`)に `taskId` を省略できない引数として足し、呼び出し元 6 ファイル(7 か所)で渡す +- **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルの文字で伝える +- **投票・タグ・問題集・コンテスト別の表**: すべてに出す。Task のレコードを足せば動き、コードの変更は `getTaskUrl()` と `addContestNameToTaskIndex()` の 2 つ +- **AOJ に後から追加されたら**: `task_id` を AOJ の ID に UPDATE し(参照する外部キーはすべて `ON UPDATE CASCADE`)、map のエントリを消す。出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る From 300bccff0135915e33fbcf1e8a68c91e8d0b8bda Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 08:24:19 +0000 Subject: [PATCH 05/15] docs: refine PR3 dummy task plan with title checks and import caveats Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 53 ++++--- .../plan-pr3.md | 130 +++++++++++++++++- 2 files changed, 157 insertions(+), 26 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 1eae0e1d8..6af75a10e 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -70,6 +70,18 @@ AOJ に無い 21 問は、すべてのコンテストに JAG のサイト(jag- サイトは URL の形式(`?plugin=attach&refer=...&openfile=...`)から PukiWiki とみられる。構成は `年度/Practice/大会区分/問題文とデータセット/`(その下に PDF と注釈)。`問題文とデータセット` と同じ階層に講評などの別のページもある。添付ファイル名は揃っていない(2006 年夏合宿は `day1.pdf` など日ごと、2008 年模擬地区予選は `problems.pdf`)。2008 年の PDF の URL は `https://jag-icpc.org/?plugin=attach&refer=2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8%2F%E5%95%8F%E9%A1%8C%E6%96%87%E3%81%A8%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BB%E3%83%83%E3%83%88&openfile=problems.pdf`(2026-10-28 にメンテナが共有)。 +**21 問の問題名は、jag-icpc.org の表記とすべて一致した**(2026-10-28 にメンテナのスクリーンショットで確認)。夏合宿 2006・冬合宿 2006・模擬地区 2005 は年度ページの問題一覧、冬合宿 2007 Day3 の E と模擬地区 2008 の J は問題文の PDF で確かめた。`Wireing Assistant` は原文どおりの綴りで、`The "Shortest" Pair of Paths` の引用符も半角。seed の title にはこの表記をそのまま使う(一覧でしか確かめていない 19 問も一覧の表記を使う。2026-10-28 に決定)。 + +年度ページの問題一覧から、今ある記号(PR2 で補正したもの)も 5 コンテスト(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005)で公式と一致した。ダミーの記号は空いている位置にちょうど収まる([7 章](#影響範囲))。PDF の表紙は、冬合宿 2006 Day3 が「Practice Contest for ACM-ICPC World Finals 2007 / Full Contest」、冬合宿 2007 Day3 が「Winter Camp for ACM-ICPC World Finals 2008 / Practice Contest II」で、どちらも年度ページ(`2006/Practice/冬合宿`、`2007/Practice/冬合宿`)より 1 年先の World Finals の年で数えている。 + +既存の title と jag-icpc.org の表記の違い(AOJ 側の title なので、PR3 の範囲外)。年度ページの問題一覧は手書きで誤記が混じるので、表記が食い違ったときは問題文の PDF の表紙で判断する: + +| コンテストと記号 | seed(AOJ) | jag-icpc.org | +| ---------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| JAG 冬合宿 2006 Day3 D | `Flame of Nucleus` | `Frame of Nucleus`(問題文の PDF の表紙と AOJ の API の `name` はどちらも `Flame`。一覧の誤記と確定。2026-10-28 にメンテナが確認) | +| JAG 夏合宿 2006 Day3 D | `Greedy, Greedy.` | `Greedy, greedy.` | +| JAG 模擬地区 2005 E | `Reading a Chord` | `Reading a Chord>`(末尾の `>` は Wiki 側の誤記とみられる) | + 一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24。模擬地区 2013 は 2026-10-28): - **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない**。問題一覧 API にも載っているが、`available` が 4(2565〜2567 は 1)で、正解者・提出がともに 0。AOJ への登録日(created at)が 2026-10-10 なので、追加されたばかりで公開前の状態と考えられる(2026-10-28 に確認。`available` の値の意味は未確認)。スプレッドシートの「未収録」は記入時点では正しく、その後に AOJ に追加されたとみられる @@ -511,29 +523,32 @@ Task を削除するコードが存在しないため、関連テーブルが孤 3643 のように、AOJ は過去の問題を後から追加することがある。そのときは AOJ の ID に移す(どの採番でも必要になる)。 +- **管理画面から取り込まない**。取り込みは、AOJ のコンテスト一覧にある問題 ID の昇順で記号を振る(`assignProblemLetters()`、`src/lib/clients/aizu_online_judge/clients.ts`)。既存の行は飛ばすので今の記号は変わらないが、問題が欠けたコンテストに後から足された問題には、ずれた記号が付く(例: 模擬地区 2005 の C が新しい ID で入ると 7 番目の G になり、今の G と重なる)。ダミーとも中身が重複する。記号が重なると `generateTable` が片方を上書きし、問題が黙って表から消える(`(contest_id, task_table_index)` に一意制約は無い)。この危険は PR3 の前からある。対象は管理画面から取り込める模擬地区(ダミーがあるのは 2005 / 2008)で、合宿系は AOJ の一覧に無いので取り込めない +- 代わりにダミーの行の `task_id` を UPDATE する。記号はそのまま残る - `task_id` を参照する外部キーは 6 つとも `ON UPDATE CASCADE`(taskanswer / tasktag / workbooktask / votegrade / votedgradecounter / votedgradestatistics、`prisma/migrations` で確認)。`task_id` を UPDATE すれば、回答・投票・タグ・問題集のデータはそのまま付いてくる - あわせて map のエントリを消す +- seed(`prisma/tasks.ts`)の行の `id` を AOJ の ID に直し、`seed_problem_letters.test.ts` の期待値(`LETTERS_OF_SHIFTED_CONTESTS`)も直す - 出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る - 移し忘れても、`NoJudge-` が残っていれば古いと一目で分かる ## 影響範囲 -コードの変更は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ。ほかは Task のレコードを足せば動く。 - -| 機能・処理 | 何で決まるか | ダミーへの影響 | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| コンテスト別の表 | `contest_id` で絞り込み、`task_table_index` の順に列を並べ(`sortAojHeaderIds`)、`table[contest_id][task_table_index]` に置く(`generateTable`)。`task_id` は使わない | 7 コンテストとも `JagRegionalProvider` の entry が既にある(`aoj_jag_regional_entries.ts`)。記号は空いている位置にちょうど収まる(下表)。変更なし | -| セルの表示名 | AOJ の表は `task_table_index` を付けて「A. Monkey Business」と出す | 変更なし | -| 問題へのリンク | すべて `getTaskUrl()` を通る(呼び出し元 9 ファイル)。AOJ の URL を組み立てる箇所はほかに無い | `NoJudgeGenerator` を足す(コードの変更 1、[下記](#リンク先の持ち方)) | -| 出典のラベル | 汎用関数 `addContestNameToTaskIndex(contestId, taskTableIndex)`(`src/lib/contests/utils/task_index_label.ts`)。`contest_id` だけを見て、AOJ 系のコンテストなら `(AOJ)` を付ける。呼び出し元は 6 ファイル(7 か所): 問題一覧(`TaskList` / `TaskListSorted`)、問題集(`WorkBookTasksTable`、`workbooks/[slug]`)、管理画面(`TaskForm`、`TaskGradeTable`) | そのままだと誤って `(AOJ)` と出る。`taskId` を足し、ダミーなら `(No Judge)` にする(コードの変更 2、[下記](#出典のラベル))。コンテスト別の表のセルには出典のラベルが出ないので、表は変わらない | -| 投票・タグ・問題集・回答 | `task_id` への外部キー(6 テーブル) | Task があればそのまま動く | -| 問題の詳細ページ | `/problems/{task_id}` | 変更なし | -| 問題の一覧の並び | `task_id` の文字列の降順(`src/lib/services/tasks.ts`、`vote_statistics.ts`) | 位置が変わるだけ | -| 入力チェック | zod は `task_id` が空でない文字列かだけを見る | 変更なし | -| `task_id` を数値として読む箇所 | 無い(`parseInt` / `Number` などは使われていない) | 変更なし | -| `task_id` を分解する箇所 | `getCourseName()` の `split('_')` だけ。AOJ の API の応答にしか使わない | 変更なし | -| 解答状況 | ユーザーがメニューで設定する。AOJ から提出結果を取り込む処理は無い | ジャッジデータで自分で確かめて AC にできる | -| 管理画面の AOJ 取り込み | Task を削除しない([5 章](#5-task-テーブルへの書き込み経路の全数調査)) | 取り込みをやり直してもダミーは消えない | +変更が要る機能は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ(新しいモジュール `no_judge.ts` と型 `UrlGenerator` の変更を含む)。ほかは Task のレコードを足せば動く。 + +| 機能・処理 | 何で決まるか | ダミーへの影響 | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| コンテスト別の表 | `contest_id` で絞り込み、`task_table_index` の順に列を並べ(`sortAojHeaderIds`)、`table[contest_id][task_table_index]` に置く(`generateTable`)。`task_id` は使わない | 7 コンテストとも `JagRegionalProvider` の entry が既にある(`aoj_jag_regional_entries.ts`)。記号は空いている位置にちょうど収まる(下表)。変更なし | +| セルの表示名 | AOJ の表は `task_table_index` を付けて「A. Monkey Business」と出す | 変更なし | +| 問題へのリンク | すべて `getTaskUrl()` を通る(呼び出し元 9 ファイル)。AOJ の URL を組み立てる箇所はほかに無い | `NoJudgeGenerator` を足す(コードの変更 1、[下記](#リンク先の持ち方)) | +| 出典のラベル | 汎用関数 `addContestNameToTaskIndex(contestId, taskTableIndex)`(`src/lib/contests/utils/task_index_label.ts`)。`contest_id` だけを見て、AOJ 系のコンテストなら `(AOJ)` を付ける。呼び出し元は 6 ファイル(各 1 か所): 問題一覧(`TaskList` / `TaskListSorted`)、問題集(`WorkBookTasksTable`、`workbooks/[slug]`)、管理画面(`TaskForm`、`TaskGradeTable`) | そのままだと誤って `(AOJ)` と出る。`taskId` を足し、ダミーなら `(No Judge)` にする(コードの変更 2、[下記](#出典のラベル))。コンテスト別の表のセルには出典のラベルが出ないので、表は変わらない | +| 投票・タグ・問題集・回答 | `task_id` への外部キー(6 テーブル) | Task があればそのまま動く | +| 問題の詳細ページ | `/problems/{task_id}` | 変更なし | +| 問題の一覧の並び | DB からは `task_id` の文字列の降順で取る(`src/lib/services/tasks.ts`、`vote_statistics.ts`)が、画面では `compareByContestIdAndTaskId()`(`src/lib/utils/task.ts`)がコンテストの優先度 → `contest_id` の降順 → 記号の昇順で並べ直す(問題一覧、投票、管理画面の難易度表)。`task_id` は使わない | 変更なし。`NoJudge-` の接頭辞で並びがずれることはない | +| 入力チェック | zod は `task_id` が空でない文字列かだけを見る | 変更なし | +| 問題 ID を数値として読む箇所 | `assignProblemLetters()`(`src/lib/clients/aizu_online_judge/utils.ts`)の `Number()` だけ(`parseContestRound()` の `parseInt` は `contest_id` を読む)。使うのは、管理画面の AOJ 取り込み(API の応答の数字の ID だけを扱う)と、`seed_problem_letters.test.ts` の基本ルールの検査 | 取り込みはダミーを通らないので影響なし。テストでは `NoJudge-` が `NaN` になるので、ダミーのあるコンテストは `LETTERS_OF_SHIFTED_CONTESTS` に入れて基本ルールの検査から外す([plan-pr3.md の段階 4](./plan-pr3.md#段階-4-seed-のデータ26-行)) | +| `task_id` を分解する箇所 | `getCourseName()` の `split('_')` だけ。AOJ の API の応答にしか使わない | 変更なし | +| 解答状況 | ユーザーがメニューで設定する。AOJ から提出結果を取り込む処理は無い | ジャッジデータで自分で確かめて AC にできる | +| 管理画面の AOJ 取り込み | Task を削除しない([5 章](#5-task-テーブルへの書き込み経路の全数調査)) | 取り込みをやり直してもダミーは消えない | コンテスト別の表での記号: @@ -549,7 +564,7 @@ Task を削除するコードが存在しないため、関連テーブルが孤 レコードの入れ方: -- seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から `AOJ_JAG` に決める +- seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から決め、ダミーのある 7 コンテストはどれも `AOJ_JAG` になる(PR3 で足す 26 行全体の一覧は [plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ)) - staging / 本番: Supabase の CSV や生の SQL は Prisma の既定値を使わないので、`id`・`contest_type`・`created_at`・`updated_at` を明示する(`docs/guides/agent-rules/prisma-db.md`)。既存の合宿系の CSV では `id` を `sha256(contest_id + title)` にしている([5 章](#id-の採番規則が経路ごとに違う)) ## リンク先 @@ -597,7 +612,7 @@ const urlGenerators: UrlGenerators = [ - 判定は出典のラベルと共通の `isNoJudgeTask()`(`startsWith('NoJudge-')`)。URL と出典のラベルで、ダミーかどうかの基準が 1 つになる - URL は問題ごとではなくページごとに決まる(21 問に対して 5 ページ)。map は `{ url, taskIds }` をページごとに並べて組み立て、各 URL を 1 回だけ書く - URL はブラウザで開けたものを、パーセントエンコードのまま貼る(ソースは ASCII だけになる)。部品から組み立てない(ファイル名やページ名の規則を推測すると、外れたときに黙って間違ったリンクになる)。そのまま返すことを単体テストで固定し、二重エンコードや読みやすくするための書き換えを防ぐ -- ダミーは DB、map はコードにあるので、map に漏れがあり得る。漏れたときは空文字を返し、AOJ のジェネレータには回らない(本物らしく見えて開けない AOJ のリンクにはならない)。seed の `NoJudge-` が全件 map にあることをテストで確かめて防ぐ +- ダミーは DB、map はコードにあるので、map に漏れがあり得る。漏れたときは空文字を返し、AOJ のジェネレータには回らない(本物らしく見えて開けない AOJ のリンクにはならない)。seed の `NoJudge-` が全件 map にあることをテストで確かめて防ぐ。逆向きに、map のキーがすべて seed にあることも確かめる(キーの打ち間違いを見つける) - 外部リンクを CI で定期的に確かめることはしない(外部サイトの状態でテストが落ちるようになる) 採らなかった案: @@ -615,7 +630,7 @@ const urlGenerators: UrlGenerators = [ - `task_id` の接頭辞 `NoJudge-` と言葉がそろう - 問題一覧や問題集では、アイコンを足さずに文字だけで、採点が無いことを伝えられる - `addContestNameToTaskIndex()` は `contest_id` と記号しか受け取らない。ダミーは本物の AOJ の問題と同じ `contest_id` を使うので、`contest_id` では見分けられず、`taskId` を足す - - 省略できない引数にする。省略できる引数にすると、渡し忘れた呼び出し元が黙って `(AOJ)` のままになる。必須なら、型チェックが 7 か所の漏れをすべて見つける。呼び出し元はどこも `task_id` を持っている + - 省略できない引数にする。省略できる引数にすると、渡し忘れた呼び出し元が黙って `(AOJ)` のままになる。必須なら、型チェックが 6 か所の漏れをすべて見つける。呼び出し元はどこも `task_id` を持っている - ダミーの判定は、URL のジェネレータと共通の `isNoJudgeTask()`(`startsWith('NoJudge-')`)を使う - 括弧は今と同じ全角。テストのデータ(`src/lib/contests/fixtures/contest_name_and_task_index.ts`)にダミーの場合を足す diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index cefe5b08a..a15068688 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -2,7 +2,7 @@ 前提と共通の決定は [plan.md](./plan.md)。欠落の一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 -**状態**: 調査(3-0)は完了、3-2 は設計まで決定(いずれも 2026-10-28)。3-1 と 3-2 の実装は未着手。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 +**状態**: 調査(3-0)は完了、3-1 / 3-2 は設計まで決定(いずれも 2026-10-28)。3-1 と 3-2 は 1 つの PR で実装する。[実装計画](#実装計画)は承認待ち。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 ## 欠落の 2 種類 @@ -30,16 +30,132 @@ | 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT)。判明分は 1123、3643、2565〜2567 | | 3-2 | AOJ に無い問題(21 問)のダミー Task レコードの設計 → 実装。設計は決定(下記) | -3-1 で決めること: +3-1 で決めたこと(2026-10-28): -- **`available` が 1 でない問題の扱い**。3643 は 2026-10-10 に AOJ に追加されたばかりで、`available` が 4、提出が 0。公開前の状態なら、AOJ のコンテスト一覧に入った後で管理画面の取り込みで入れられるかもしれない。手で INSERT するか、一覧に入るのを待つかを決める。先に `available` の値の意味と、判明分(1123 を含む)の値を確かめる +- **対象は 5 問すべて**(1123、3643、2565〜2567)。通常の AOJ の問題として、問題 ID のまま seed と staging / 本番に入れる +- **3643 も今回入れる**。2026-10-10 に AOJ に追加されたばかりで、`available` が 4、提出が 0。公開前なら、しばらく AOJ で提出できない可能性がある(`available` の値の意味は未確認)。不採用: AOJ のコンテスト一覧に入るのを待ち、管理画面の取り込みで入れる 3-2 で決めたこと(2026-10-28)。根拠、採らなかった案の詳細、影響範囲は [investigation.md の 7 章](./investigation.md#7-pr3-のダミーの設計と影響範囲)。 - **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。不採用: 数字(100000 番台、50xx 番台)、`Missing-` などの接頭辞 - **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる。不採用: PDF への直リンク、`問題文とデータセット` のページ -- **リンク先の持ち方**: `getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。`UrlGenerator.canHandle` に `taskId` を足す(既存の 2 つの実装は書き換え不要)。判定は出典のラベルと共通の `isNoJudgeTask()`、URL は `task_id` をキーにした map から引く。map は `{ url, taskIds }` をページごとに並べて組み立てる。URL はブラウザで開けたものをパーセントエンコードのまま貼る。テストで、URL をそのまま返すこと、ジェネレータの並び順、seed の `NoJudge-` が全件 map にあることを固定する。不採用: `getTaskUrl()` の先頭で map を引く、Task に `url` 列を足す -- **出典のラベル**: ダミーは `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。汎用関数 `addContestNameToTaskIndex()`(`src/lib/contests/utils/task_index_label.ts`)に `taskId` を省略できない引数として足し、呼び出し元 6 ファイル(7 か所)で渡す +- **リンク先の持ち方**: `getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。`UrlGenerator.canHandle` に `taskId` を足す(既存の 2 つの実装は書き換え不要)。判定は出典のラベルと共通の `isNoJudgeTask()`、URL は `task_id` をキーにした map から引く。map は `{ url, taskIds }` をページごとに並べて組み立てる。URL はブラウザで開けたものをパーセントエンコードのまま貼る。テストで、URL をそのまま返すこと、ジェネレータの並び順、seed の `NoJudge-` が全件 map にあること、map のキーがすべて seed にあることを固定する。不採用: `getTaskUrl()` の先頭で map を引く、Task に `url` 列を足す +- **出典のラベル**: ダミーは `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。汎用関数 `addContestNameToTaskIndex()`(`src/lib/contests/utils/task_index_label.ts`)に `taskId` を省略できない引数として足し、呼び出し元 6 ファイル(各 1 か所)で渡す - **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルの文字で伝える -- **投票・タグ・問題集・コンテスト別の表**: すべてに出す。Task のレコードを足せば動き、コードの変更は `getTaskUrl()` と `addContestNameToTaskIndex()` の 2 つ -- **AOJ に後から追加されたら**: `task_id` を AOJ の ID に UPDATE し(参照する外部キーはすべて `ON UPDATE CASCADE`)、map のエントリを消す。出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る +- **投票・タグ・問題集・コンテスト別の表**: すべてに出す。Task のレコードを足せば動く。変更が要る機能は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ(新しいモジュール `no_judge.ts` と型 `UrlGenerator` の変更を含む。[実装計画](#実装計画)) +- **AOJ に後から追加されたら**: 管理画面からは取り込まない(取り込みは AOJ の一覧の ID 順で記号を振るので、欠けたコンテストではずれた記号が付き、今の記号と重なる。[investigation.md](./investigation.md#aoj-に後から追加された場合))。代わりに DB の `task_id` を AOJ の ID に UPDATE し(参照する外部キーはすべて `ON UPDATE CASCADE`。記号はそのまま残る)、map のエントリを消す。seed(`prisma/tasks.ts`)の行の `id` と、`seed_problem_letters.test.ts` の期待値も直す。出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る + +## 実装計画 + +3-1(5 問)と 3-2(21 問)を 1 つの PR で入れる。リスクの低い順に 5 段階。各段階はテストを先に書く。 + +### 段階 1: ダミーの判定と URL(新しいモジュール。既存のコードには影響しない) + +`src/lib/contests/utils/no_judge.ts` を新しく作り、`src/lib/contests/index.ts` から export する。 + +```typescript +export const NO_JUDGE_TASK_ID_PREFIX = 'NoJudge-'; + +export function isNoJudgeTask(taskId: string): boolean; + +// Built from a private { url, taskIds }[] of the 5 jag-icpc.org pages. +export const NO_JUDGE_TASK_URLS: ReadonlyMap; +``` + +- `lib/contests` に置く理由: `src/lib/utils/task.ts` は `#lib/contests/index.js` を import している。判定を `lib/utils` に置くと、`task_index_label.ts`(`lib/contests`)からの import で循環する。`lib/contests` は `lib/utils` に依存していない +- URL の map は、同じファイルの中で `{ url, taskIds }` を 5 ページ分並べた配列(こちらは外に出さない)から組み立て、読み取り専用の `NO_JUDGE_TASK_URLS` として export する。取り出し用の関数は作らない(段階 4 のテストで seed と突き合わせるのにキーの列挙が要る) +- テスト(`src/lib/contests/utils/no_judge.test.ts`): + - `returns true for task ids starting with NoJudge-` + - `returns false for AOJ and AtCoder task ids`(`'1123'`、`'abc300_a'`、小文字の `'nojudge-...'`) + - `maps each NoJudge task to its jag-icpc.org page url verbatim`(5 ページそれぞれ `get` で引く。期待値は URL のリテラル) + - `has no entry for AOJ task ids` + +### 段階 2: URL ジェネレータ(共有の型を変えるが、既存の実装は書き換えない) + +- `src/lib/types/url.ts`: `canHandle(contestId: string, taskId: string): boolean` に広げ、TSDoc も直す +- `src/lib/utils/task.ts`: `NoJudgeGenerator` を足して `AojGenerator` より前に置き、`getTaskUrl()` の呼び出しで `taskId` を渡す。`canHandle` は `isNoJudgeTask(taskId)`、`generateUrl` は `NO_JUDGE_TASK_URLS.get(taskId) ?? ''` を返す(map に漏れがあっても AOJ のジェネレータには回さない)。データは持たない +- テスト(`src/test/lib/utils/test_cases/task_url.ts` に足す): + - `returns the jag-icpc.org page for a NoJudge task in a JAG contest` + - `returns the AOJ problem url for an AOJ task in the same JAG contest`(並び順の固定) + +### 段階 3: 出典のラベル(汎用関数のシグネチャを変え、呼び出し元 6 ファイルに波及する) + +```typescript +export const addContestNameToTaskIndex = ( + contestId: string, + taskTableIndex: string, + taskId: string, +): string; +``` + +- ダミーなら `` `${contestName} - ${taskTableIndex}(No Judge)` `` を返す +- 呼び出し元 6 ファイル(各 1 か所)で `task_id` を渡す: `TaskList.svelte`、`TaskListSorted.svelte`、`TaskForm.svelte`、`WorkBookTasksTable.svelte`、`src/routes/workbooks/[slug]/+page.svelte`、`TaskGradeTable.svelte`。漏れは型チェック(`pnpm check`)が見つける +- テスト: `src/lib/contests/fixtures/contest_name_and_task_index.ts` の型に `taskId` を足して既存のケースを直し、ダミーのケースを足す(`appends (No Judge) for a NoJudge task in a JAG contest`) + +### 段階 4: seed のデータ(26 行) + +`prisma/tasks.ts` に 26 行を足す。`name` と `title` は同じ値にし、21 問は jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記どおりにする([investigation.md](./investigation.md#欠落の一覧pr3))。問題文の PDF の表紙で確かめたのは 2 問だけだが、残る 19 問も一覧の表記を使う(2026-10-28 に決定)。 + +| 種類 | `id` | `contest_id` と記号 | +| ---- | ------------------------------------- | --------------------------- | +| 3-1 | `1123` | `ICPCPrelim2002` E | +| 3-1 | `3643` | `JAGSummer2024-day3` G | +| 3-1 | `2565` / `2566` / `2567` | `JAGRegional2013` A / B / C | +| 3-2 | `NoJudge-{contest_id}_{記号の小文字}` | 7 コンテストの 21 問 | + +先にテストの期待値を直す: + +- `src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts` + - 件数を 151 コンテスト・1,329 問から 151 コンテスト・1,355 問にする(コンテストは増えない) + - `LETTERS_OF_SHIFTED_CONTESTS`: ダミーのある 7 コンテストのうち、すでに載っている 6 コンテストにはダミーの ID と記号を足し、載っていない `JAGRegional2008` は新しく足す。`assignProblemLetters()` は ID を `Number()` で並べるので、`NoJudge-` を含むコンテストは基本ルールの検査に通せない + - `JAGRegional2013`(2565〜2574)と `JAGSummer2024-day3`(3637〜3647)は、足すと ID が連番になって基本ルールに戻るので、`LETTERS_OF_SHIFTED_CONTESTS` から外す。`ICPCPrelim2002` は 1119〜1123 で基本ルールのまま + - テストを足す: `every contest with NoJudge tasks is in LETTERS_OF_SHIFTED_CONTESTS`。ダミーを足して一覧に入れ忘れると、基本ルールの検査が `NaN` を含む並べ替えになり、落ちるとは限らないため + - `LETTERS_OF_SHIFTED_CONTESTS` の上のコメント(「AOJ に無い問題があると基本ルールがずれる」)に、`NoJudge-` の ID は `Number()` で並べられないことも書き足す +- `src/lib/contests/utils/no_judge.test.ts` に seed との突き合わせを足す + - `every NoJudge task in prisma/tasks.ts has a page url`(`NO_JUDGE_TASK_URLS.has`) + - `every task id with a page url exists in prisma/tasks.ts`(`NO_JUDGE_TASK_URLS.keys()`。map のキーの打ち間違いを見つける) + +### 段階 5: 投入用の行の一覧と仕上げ + +- メンテナが CSV を作るための 26 行の一覧(`task_id`、`contest_id`、`contest_type`、`task_table_index`、`title`)を、下の「[staging / 本番への投入](#staging--本番への投入メンテナ)」に足す。`contest_type` は `ICPCPrelim2002`(1123)だけ `AOJ_ICPC`、ほかの 25 行は `AOJ_JAG`。`id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする +- ガイドとスキルを、AOJ に無い問題をダミーで入れる手順に合わせて直す(`docs/guides/agent-rules/` のルールは変更不要) + - `.agents/skills/add-tasks/instructions.md`(Collect rows): 今は「最後より前の問題が AOJ に無ければ、記号をスプレッドシートから取り `LETTERS_OF_SHIFTED_CONTESTS` に足す」とだけある。AOJ に無い問題は、末尾でも `NoJudge-{contest_id}_{記号の小文字}` の行で入れ、`no_judge.ts` に jag-icpc.org のページを登録し、コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に足す、と書く。「`id` を contest_id から導かない」の例外であることも明記する。AOJ にあるがコンテスト一覧に無い問題は、問題 ID のまま入れる。Tests の `contest_name_and_task_index.ts` は `taskId` が必須になる + - `docs/guides/how-to-add-contest-table-provider.md`(「JAG 模擬地区は AOJ と AtCoder が混在する」の「記号」): 同じ手順を 1 行で足す +- `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`git diff --check` +- 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と出典のラベルを目で確かめる +- 共有の型と汎用関数のシグネチャを変えるので、Codex でクロスレビューする + +### staging / 本番への投入(メンテナ) + +コードのデプロイの後に CSV を入れる。先に入れると、デプロイまでの間、ダミーが AOJ の開けないリンクと `(AOJ)` のラベルで表示される。Supabase の CSV は Prisma の既定値を使わないので、`id`・`contest_type`・`created_at`・`updated_at` を明示する(`docs/guides/agent-rules/prisma-db.md`。`created_at` は DB に既定値があっても、CSV の取り込みで空になった例がある)。行の一覧は実装の段階 5 でここに足す。 + +CSV を入れたら、次の SQL で記号を確かめる。`(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える。seed はテストが守るが、CSV は守らない。 + +```sql +-- Expect no rows: a duplicated letter hides one problem in the contest table. +SELECT contest_id, task_table_index, COUNT(*) +FROM task +WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') +GROUP BY contest_id, task_table_index +HAVING COUNT(*) > 1; + +-- Expect the letters in the table below. +SELECT contest_id, COUNT(*) AS task_count, STRING_AGG(task_table_index, '' ORDER BY task_table_index) AS letters +FROM task +WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') +GROUP BY contest_id +ORDER BY contest_id; +``` + +| `contest_id` | 問題数 | 記号 | +| ------------------ | ------ | ----------- | +| ICPCPrelim2002 | 5 | ABCDE | +| JAGRegional2005 | 9 | ABCDEFGHI | +| JAGRegional2008 | 10 | ABCDEFGHIJ | +| JAGRegional2013 | 10 | ABCDEFGHIJ | +| JAGSummer2006-day1 | 3 | ABC | +| JAGSummer2006-day3 | 7 | ABCDEFG | +| JAGSummer2024-day3 | 11 | ABCDEFGHIJK | +| JAGWinter2006-day2 | 9 | ABCDEFGHI | +| JAGWinter2006-day3 | 10 | ABCDEFGHIJ | +| JAGWinter2007-day3 | 10 | ABCDEFGHIJ | From 4264ba2608efc316d184ae5d30b731f47e572fff Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 08:42:50 +0000 Subject: [PATCH 06/15] docs: mark PR0-PR2.5 verification done and update PR3 plan Co-Authored-By: Claude Sonnet 5.5 --- .../task-title-index-change-impact/done.md | 29 +- .../investigation.md | 252 ++++++++---------- .../plan-pr3.md | 137 ++++++---- 3 files changed, 202 insertions(+), 216 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md index d404545e0..130168eb0 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md @@ -2,27 +2,18 @@ コードと git の履歴から読み取れない判断と事実だけを、項目ごとにまとめる。変更したファイルや関数は各 PR の差分を見る。全体像は [plan.md](./plan.md)、根拠データは [investigation.md](./investigation.md)。 -| PR | 内容 | 状態 | -| -------------- | --------------------------------------------------------------------- | ------------------------------------------------ | -| PR0(#4212) | 出典欄の AOJ の書式 | 完了 | -| PR1(#4217) | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を問題記号にする | 完了 | -| PR1.5(#4219) | AOJ API の移転に追従する(#4028) | 完了 | -| PR2(#4220) | JAG 合宿系と JAG 模擬地区(66 / 625)を問題記号にする(#4030) | staging に反映、本番は未反映 | -| PR2.1(#4222) | DB に無い 10 コンテストを入れる(#4221) | staging に反映(データも投入済み)、本番は未反映 | -| PR2.5 | 76 コンテストのテーブルを、グループ「JAG 模擬地区」に作る(#4026) | 実装済み、未マージ | +| PR | 内容 | 状態 | +| -------------- | --------------------------------------------------------------------- | ------------------------------ | +| PR0(#4212) | 出典欄の AOJ の書式 | 完了 | +| PR1(#4217) | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を問題記号にする | 完了 | +| PR1.5(#4219) | AOJ API の移転に追従する(#4028) | 完了 | +| PR2(#4220) | JAG 合宿系と JAG 模擬地区(66 / 625)を問題記号にする(#4030) | 完了 | +| PR2.1(#4222) | DB に無い 10 コンテストを入れる(#4221) | 完了(本番のデータも投入済み) | +| PR2.5 | 76 コンテストのテーブルを、グループ「JAG 模擬地区」に作る(#4026) | 完了 | -## 残っている作業 - -### 本番・staging での確認 +本番・staging での確認(移行、出典欄の表示、データの投入、テーブルの目視確認)はすべて済んだ(2026-10-28 にメンテナが確認)。 -- PR2: 本番(`main` への昇格後)で、移行が通ること -- PR2: 問題一覧の出典欄の表示(例: `JAG 夏合宿 2024 Day3 - H(AOJ)`) -- PR2.1: 本番(`main` への昇格後)で、デプロイが終わってから 4 コンテストを取り込み、PR2 の移行が通った後に CSV を投入する(staging と同じ手順) -- PR2.1 の CSV には `updated_at` を含める(`@updatedAt` は Prisma 側の実装で、DB に既定値が無い。`created_at` / `grade` / `atcoder_problems_difficulty` には既定値がある)。投入は `invalidateTaskCaches()` を経ないので、反映はキャッシュの TTL(1 時間)の後になる -- PR1: コンテストテーブルのスクリーンショットでの目視確認 -- PR2.5: staging で「JAG 模擬地区」の表示を目視で確認する(並び、括弧書き、AtCoder の 4 コンテストのセルのリンク先)。開発サーバーでは、2026-10-25 にメンテナが同じ 3 点を確かめた -- PR2.5: 本番では、PR2 の移行が通ってから表に出ること。移行の前に出ると、合宿系の問題 ID の index と `'2389. …'` の title が表に出る -- PR2.5: 本番で PR2.1 のデータ(4 コンテストの取り込みと CSV)を入れるまでは、「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(下の残タスク) +## 残っている作業 ### 残タスク diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 6af75a10e..d4dd0f3cd 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -2,15 +2,15 @@ [plan.md](./plan.md) と PR ごとの計画(`plan-pr*.md`)の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** -| 章 | 内容 | 主に使う PR | -| ----------------------------------------------- | ------------------------------------------------------------------ | ------------------- | -| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、欠落、表示順 | PR2 / 2.1 / 2.5 / 3 | -| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | -| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | -| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | -| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | -| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | -| [7](#7-pr3-のダミーの設計と影響範囲) | AOJ に無い問題の `task_id`、リンク、出典のラベル、見た目、影響範囲 | PR3 | +| 章 | 内容 | 主に使う PR | +| ----------------------------------------------- | --------------------------------------------------- | --------------- | +| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、表示順 | PR2 / 2.1 / 2.5 | +| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | +| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | +| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | +| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | +| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | +| [7](#7-pr3-欠けている問題) | 欠けている問題の調査、ダミーの設計、影響範囲 | PR3 | PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [done.md](./done.md)。 @@ -45,50 +45,6 @@ PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの 8 コンテスト / 45 行(合宿系 32、模擬地区 13)。基本ルールから値が変わるのは 32 行(合宿系 21、模擬地区 11)。 -## 欠落の一覧(PR3) - -スプレッドシートで「AOJ 未収録」と書かれている問題。22 問(合宿系 18、模擬地区 4)。うち Interactive String Guessing は AOJ 3643 にあると確認済み(下記)。残る 21 問は、問題一覧 API で問題名を部分一致で検索しても ID が見つからなかった(2026-10-28 にメンテナが確認。表記ゆれのありうる Wireing / "Shortest" / Geometry?! も部分一致で確認)。 - -| コンテスト | 記号と問題名 | jag-icpc.org の年度ページ | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | [2006/Practice/夏合宿][jag-2006-summer] | -| JAG 夏合宿 2006 Day3 | A Rolling Cubes | [2006/Practice/夏合宿][jag-2006-summer] | -| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | [2006/Practice/冬合宿][jag-2006-winter] | -| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | [2006/Practice/冬合宿][jag-2006-winter] | -| JAG 冬合宿 2007 Day3 | E Networking Company | [2007/Practice/冬合宿][jag-2007-winter] | -| JAG 夏合宿 2024 Day3 | G Interactive String Guessing(AOJ 3643。確認済み) | — | -| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | [2005/Practice/模擬地区予選][jag-2005-regional] | -| JAG 模擬地区 2008 | J Colony Maintenance | [2008/Practice/模擬地区予選][jag-2008-regional] | - -[jag-2006-summer]: https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF -[jag-2006-winter]: https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF -[jag-2007-winter]: https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF -[jag-2005-regional]: https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 -[jag-2008-regional]: https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 - -AOJ に無い 21 問は、すべてのコンテストに JAG のサイト(jag-icpc.org、スプレッドシートの情報源の一つ)の年度ページがあり、5 ページとも問題文へのリンクがある(2026-10-28 にメンテナが確認)。2006 年夏合宿では、問題文は日ごとの PDF で、ジャッジデータも収録されている。 - -サイトは URL の形式(`?plugin=attach&refer=...&openfile=...`)から PukiWiki とみられる。構成は `年度/Practice/大会区分/問題文とデータセット/`(その下に PDF と注釈)。`問題文とデータセット` と同じ階層に講評などの別のページもある。添付ファイル名は揃っていない(2006 年夏合宿は `day1.pdf` など日ごと、2008 年模擬地区予選は `problems.pdf`)。2008 年の PDF の URL は `https://jag-icpc.org/?plugin=attach&refer=2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8%2F%E5%95%8F%E9%A1%8C%E6%96%87%E3%81%A8%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BB%E3%83%83%E3%83%88&openfile=problems.pdf`(2026-10-28 にメンテナが共有)。 - -**21 問の問題名は、jag-icpc.org の表記とすべて一致した**(2026-10-28 にメンテナのスクリーンショットで確認)。夏合宿 2006・冬合宿 2006・模擬地区 2005 は年度ページの問題一覧、冬合宿 2007 Day3 の E と模擬地区 2008 の J は問題文の PDF で確かめた。`Wireing Assistant` は原文どおりの綴りで、`The "Shortest" Pair of Paths` の引用符も半角。seed の title にはこの表記をそのまま使う(一覧でしか確かめていない 19 問も一覧の表記を使う。2026-10-28 に決定)。 - -年度ページの問題一覧から、今ある記号(PR2 で補正したもの)も 5 コンテスト(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005)で公式と一致した。ダミーの記号は空いている位置にちょうど収まる([7 章](#影響範囲))。PDF の表紙は、冬合宿 2006 Day3 が「Practice Contest for ACM-ICPC World Finals 2007 / Full Contest」、冬合宿 2007 Day3 が「Winter Camp for ACM-ICPC World Finals 2008 / Practice Contest II」で、どちらも年度ページ(`2006/Practice/冬合宿`、`2007/Practice/冬合宿`)より 1 年先の World Finals の年で数えている。 - -既存の title と jag-icpc.org の表記の違い(AOJ 側の title なので、PR3 の範囲外)。年度ページの問題一覧は手書きで誤記が混じるので、表記が食い違ったときは問題文の PDF の表紙で判断する: - -| コンテストと記号 | seed(AOJ) | jag-icpc.org | -| ---------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| JAG 冬合宿 2006 Day3 D | `Flame of Nucleus` | `Frame of Nucleus`(問題文の PDF の表紙と AOJ の API の `name` はどちらも `Flame`。一覧の誤記と確定。2026-10-28 にメンテナが確認) | -| JAG 夏合宿 2006 Day3 D | `Greedy, Greedy.` | `Greedy, greedy.` | -| JAG 模擬地区 2005 E | `Reading a Chord` | `Reading a Chord>`(末尾の `>` は Wiki 側の誤記とみられる) | - -一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24。模擬地区 2013 は 2026-10-28): - -- **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない**。問題一覧 API にも載っているが、`available` が 4(2565〜2567 は 1)で、正解者・提出がともに 0。AOJ への登録日(created at)が 2026-10-10 なので、追加されたばかりで公開前の状態と考えられる(2026-10-28 に確認。`available` の値の意味は未確認)。スプレッドシートの「未収録」は記入時点では正しく、その後に AOJ に追加されたとみられる -- **JAG 模擬地区 2013 の A Broken Audio Signal / B Restore Calculation / C SIRO Challenge**: スプレッドシートではリンク付き(未収録の表記が無い)で、リンク先は AOJ 2565 / 2566 / 2567。AOJ の模擬地区 2013 の一覧(`challenges/cl/jag/regional`、本番の 7 問、2568〜2574)には無いが、問題一覧 API に同じ ID・同じ問題名で載っている。コンテスト一覧から外れているだけで、1123・3643 と同じ種類。A〜J が 2565〜2574 の連番になる - -どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ のドメインは `https://onlinejudge.u-aizu.ac.jp/` に変わった(旧 API の `judgeapi.u-aizu.ac.jp` は HTTP 410)。API の向き先(`/api/`)と開発コンテナの許可リストは PR1.5 で新ドメインに更新済み。 - ## DB に無いコンテスト PR2.1 で入れる。contest_id と問題番号は 2026-10-24 にメンテナから受け取った。 @@ -403,7 +359,7 @@ PR2 後: index='A', title='2389. Asynchronous Exceptions' # 4. 検討の経緯(採らなかった案) -**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。 +**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。PR3 の採らなかった案は [plan-pr3.md](./plan-pr3.md#採らなかった案) にまとめた。 ## `title` に記号を入れる案を採らない理由 @@ -495,62 +451,66 @@ Task を削除するコードが存在しないため、関連テーブルが孤 --- -# 7. PR3 のダミーの設計と影響範囲 +# 7. PR3: 欠けている問題 -[plan-pr3.md](./plan-pr3.md) の 3-2 の根拠。対象は AOJ に無い 21 問([欠落の一覧](#欠落の一覧pr3))。コードの確認は 2026-10-28 時点。 +[plan-pr3.md](./plan-pr3.md) の根拠。確認はすべて 2026-10-28 時点(メンテナのスクリーンショットと AOJ の API、リポジトリのコード)。採らなかった案は [plan-pr3.md](./plan-pr3.md#採らなかった案) に 1 か所にまとめた。 -## `task_id` の形式 +## 調べ方と結果 -`NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。 +公式の問題なのに seed に無いものを、AOJ の問題一覧 API で問題名から探した(部分一致)。対象は、スプレッドシートで「AOJ 未収録」とある 22 問、スプレッドシートのリンク先の title が AOJ で見つからなかった 3 問(JAG 模擬地区 2013 の A〜C)、PR1 で見つかった 1 問(ICPC 国内予選 2002 の E)。 -- 接頭辞は、コードが分岐に使う性質(採点の仕組みが無い)をそのまま表す。ID だけで欠落の問題と分かる -- 数字でも小文字始まりでもないので、AOJ(数字)とも AtCoder(`abc300_a` など)とも構造上ぶつからない -- 記号を小文字にするのは AtCoder の形式(`abc300_a`、`jag2018summer_day2_a`)に合わせるため -- `contest_id` と記号の組は一意なので、ID も一意になる -- 英数字・`-`・`_` だけなので、`/problems/{task_id}` にそのまま入る -- 判定は `startsWith('NoJudge-')` で足りる +結果は 26 問で、AOJ にあるものが 5 問、AOJ に無いものが 21 問。 -採らなかった案: +- スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない。書いた後で AOJ に追加された問題がある(3643) +- AOJ のコンテスト一覧(管理画面の取り込み元)に無いことも、AOJ に無いことの保証にならない。問題一覧 API には載っている問題がある(下の 5 問) +- AOJ は過去の問題を後から追加するので、この結果は確認した時点のもの -| 案 | 採らない理由 | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 数字(100000 番台など) | 既存のコードは壊れないが、AOJ の本物の ID と見分けられない。数字の範囲は AOJ が決める領域(通常の問題は 40xx 番台まで、AOJ 自身のダミーに 9900〜9995)で、衝突の心配が残る。map の漏れが、本物らしく見えて開けない AOJ のリンク(`/problems/100000`)になる | -| 50xx 番台 | AOJ の通常の採番がいずれ届く | -| `Missing-` | このノートの「欠落」は、AOJ にあるがコンテスト一覧に無い問題(1123 など)も含むので、意味がずれる。欠けているのは採点の仕組みだけで問題文はあるのに、中身の無い仮のレコードやエラーページにも読める。分岐の理由ではなく、取り込みから漏れた経緯を表す | -| `jag-icpc_` など出所を表す接頭辞 | 分岐の理由(採点が無い)ではなく、問題文の置き場所を表す | +## AOJ にある 5 問 -### AOJ に後から追加された場合 +どれも AOJ のコンテスト一覧には無いが、問題一覧 API に同じ問題名で載っている。 -3643 のように、AOJ は過去の問題を後から追加することがある。そのときは AOJ の ID に移す(どの採番でも必要になる)。 +| 問題 | AOJ の問題 ID | メモ | +| --------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| ICPC 国内予選 2002 の E(Enclosing Circles) | 1123 | A〜D は 1119〜1122 | +| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | 3643 | 2026-10-10 に AOJ に追加された。`available` が 4(ほかの問題は 1)で、提出が 0。公開前とみられるが、`available` の値の意味は未確認 | +| JAG 模擬地区 2013 の A〜C(Broken Audio Signal / Restore Calculation / SIRO Challenge) | 2565〜2567 | D〜J(2568〜2574)と連番 | -- **管理画面から取り込まない**。取り込みは、AOJ のコンテスト一覧にある問題 ID の昇順で記号を振る(`assignProblemLetters()`、`src/lib/clients/aizu_online_judge/clients.ts`)。既存の行は飛ばすので今の記号は変わらないが、問題が欠けたコンテストに後から足された問題には、ずれた記号が付く(例: 模擬地区 2005 の C が新しい ID で入ると 7 番目の G になり、今の G と重なる)。ダミーとも中身が重複する。記号が重なると `generateTable` が片方を上書きし、問題が黙って表から消える(`(contest_id, task_table_index)` に一意制約は無い)。この危険は PR3 の前からある。対象は管理画面から取り込める模擬地区(ダミーがあるのは 2005 / 2008)で、合宿系は AOJ の一覧に無いので取り込めない -- 代わりにダミーの行の `task_id` を UPDATE する。記号はそのまま残る -- `task_id` を参照する外部キーは 6 つとも `ON UPDATE CASCADE`(taskanswer / tasktag / workbooktask / votegrade / votedgradecounter / votedgradestatistics、`prisma/migrations` で確認)。`task_id` を UPDATE すれば、回答・投票・タグ・問題集のデータはそのまま付いてくる -- あわせて map のエントリを消す -- seed(`prisma/tasks.ts`)の行の `id` を AOJ の ID に直し、`seed_problem_letters.test.ts` の期待値(`LETTERS_OF_SHIFTED_CONTESTS`)も直す -- 出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る -- 移し忘れても、`NoJudge-` が残っていれば古いと一目で分かる +## AOJ に無い 21 問 -## 影響範囲 +表記ゆれのありそうな語(`Wireing`、`Shortest`、`Geometry`)も部分一致で探したが、見つからなかった。 + +| コンテスト | 記号と問題名 | jag-icpc.org の年度ページ | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | [2006/Practice/夏合宿][jag-2006-summer] | +| JAG 夏合宿 2006 Day3 | A Rolling Cubes | [2006/Practice/夏合宿][jag-2006-summer] | +| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | [2006/Practice/冬合宿][jag-2006-winter] | +| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | [2006/Practice/冬合宿][jag-2006-winter] | +| JAG 冬合宿 2007 Day3 | E Networking Company | [2007/Practice/冬合宿][jag-2007-winter] | +| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | [2005/Practice/模擬地区予選][jag-2005-regional] | +| JAG 模擬地区 2008 | J Colony Maintenance | [2008/Practice/模擬地区予選][jag-2008-regional] | + +[jag-2006-summer]: https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF +[jag-2006-winter]: https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF +[jag-2007-winter]: https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF +[jag-2005-regional]: https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 +[jag-2008-regional]: https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 + +- 合宿系 17 問、模擬地区 4 問。7 コンテストはすべて 2005〜2008 年で、冬合宿 2006 の Day2 / Day3 に 13 問が集中する +- 問題名は jag-icpc.org の表記とすべて一致した。19 問は年度ページの問題一覧で、2 問(冬合宿 2007 Day3 の E、模擬地区 2008 の J)は問題文の PDF の表紙で確かめた。seed の title にはこの表記を使う +- `Wireing Assistant` は原文どおりの綴り。`The "Shortest" Pair of Paths` の引用符は半角 + +## jag-icpc.org -変更が要る機能は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ(新しいモジュール `no_judge.ts` と型 `UrlGenerator` の変更を含む)。ほかは Task のレコードを足せば動く。 - -| 機能・処理 | 何で決まるか | ダミーへの影響 | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| コンテスト別の表 | `contest_id` で絞り込み、`task_table_index` の順に列を並べ(`sortAojHeaderIds`)、`table[contest_id][task_table_index]` に置く(`generateTable`)。`task_id` は使わない | 7 コンテストとも `JagRegionalProvider` の entry が既にある(`aoj_jag_regional_entries.ts`)。記号は空いている位置にちょうど収まる(下表)。変更なし | -| セルの表示名 | AOJ の表は `task_table_index` を付けて「A. Monkey Business」と出す | 変更なし | -| 問題へのリンク | すべて `getTaskUrl()` を通る(呼び出し元 9 ファイル)。AOJ の URL を組み立てる箇所はほかに無い | `NoJudgeGenerator` を足す(コードの変更 1、[下記](#リンク先の持ち方)) | -| 出典のラベル | 汎用関数 `addContestNameToTaskIndex(contestId, taskTableIndex)`(`src/lib/contests/utils/task_index_label.ts`)。`contest_id` だけを見て、AOJ 系のコンテストなら `(AOJ)` を付ける。呼び出し元は 6 ファイル(各 1 か所): 問題一覧(`TaskList` / `TaskListSorted`)、問題集(`WorkBookTasksTable`、`workbooks/[slug]`)、管理画面(`TaskForm`、`TaskGradeTable`) | そのままだと誤って `(AOJ)` と出る。`taskId` を足し、ダミーなら `(No Judge)` にする(コードの変更 2、[下記](#出典のラベル))。コンテスト別の表のセルには出典のラベルが出ないので、表は変わらない | -| 投票・タグ・問題集・回答 | `task_id` への外部キー(6 テーブル) | Task があればそのまま動く | -| 問題の詳細ページ | `/problems/{task_id}` | 変更なし | -| 問題の一覧の並び | DB からは `task_id` の文字列の降順で取る(`src/lib/services/tasks.ts`、`vote_statistics.ts`)が、画面では `compareByContestIdAndTaskId()`(`src/lib/utils/task.ts`)がコンテストの優先度 → `contest_id` の降順 → 記号の昇順で並べ直す(問題一覧、投票、管理画面の難易度表)。`task_id` は使わない | 変更なし。`NoJudge-` の接頭辞で並びがずれることはない | -| 入力チェック | zod は `task_id` が空でない文字列かだけを見る | 変更なし | -| 問題 ID を数値として読む箇所 | `assignProblemLetters()`(`src/lib/clients/aizu_online_judge/utils.ts`)の `Number()` だけ(`parseContestRound()` の `parseInt` は `contest_id` を読む)。使うのは、管理画面の AOJ 取り込み(API の応答の数字の ID だけを扱う)と、`seed_problem_letters.test.ts` の基本ルールの検査 | 取り込みはダミーを通らないので影響なし。テストでは `NoJudge-` が `NaN` になるので、ダミーのあるコンテストは `LETTERS_OF_SHIFTED_CONTESTS` に入れて基本ルールの検査から外す([plan-pr3.md の段階 4](./plan-pr3.md#段階-4-seed-のデータ26-行)) | -| `task_id` を分解する箇所 | `getCourseName()` の `split('_')` だけ。AOJ の API の応答にしか使わない | 変更なし | -| 解答状況 | ユーザーがメニューで設定する。AOJ から提出結果を取り込む処理は無い | ジャッジデータで自分で確かめて AC にできる | -| 管理画面の AOJ 取り込み | Task を削除しない([5 章](#5-task-テーブルへの書き込み経路の全数調査)) | 取り込みをやり直してもダミーは消えない | - -コンテスト別の表での記号: +JAG のサイトで、スプレッドシートの情報源の一つ。URL の形式(`?plugin=attach&refer=...&openfile=...`)から PukiWiki とみられる。 + +- 構成は `年度/Practice/大会区分/問題文とデータセット/`(その下に問題文の PDF と注釈)。`問題文とデータセット` と同じ階層に、講評などのページもある +- 21 問の 7 コンテストは、5 つの年度ページにまとまる。どのページにも問題文へのリンクがある +- 添付ファイル名は揃っていない(2006 年夏合宿は `day1.pdf` など日ごと、2008 年模擬地区予選は `problems.pdf`) +- 年度ページの問題一覧は手書きで、誤記が混じる(例: PDF の表紙と AOJ が `Flame of Nucleus` の問題を、一覧は `Frame of Nucleus` と書いている)。表記が食い違ったら、問題文の PDF の表紙で判断する + +## コンテスト別の表での記号 + +今ある記号(PR2 で補正)は、年度ページの問題一覧とも一致した(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005)。ダミーは空いている位置にちょうど収まる。 | コンテスト | 今ある記号 | ダミーで足す記号 | 合わせると | | ------------------ | ---------------- | ------------------- | ---------- | @@ -562,28 +522,28 @@ Task を削除するコードが存在しないため、関連テーブルが孤 | JAGRegional2005 | A, B, D, E, G, I | C, F, H | A〜I | | JAGRegional2008 | A〜I | J | A〜J | -レコードの入れ方: +AOJ にある 5 問も、空いている位置に入る(ICPC 国内予選 2002 は A〜E、模擬地区 2013 は A〜J、夏合宿 2024 Day3 は A〜K になる)。 -- seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から決め、ダミーのある 7 コンテストはどれも `AOJ_JAG` になる(PR3 で足す 26 行全体の一覧は [plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ)) -- staging / 本番: Supabase の CSV や生の SQL は Prisma の既定値を使わないので、`id`・`contest_type`・`created_at`・`updated_at` を明示する(`docs/guides/agent-rules/prisma-db.md`)。既存の合宿系の CSV では `id` を `sha256(contest_id + title)` にしている([5 章](#id-の採番規則が経路ごとに違う)) +## ダミーの `task_id` + +`NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。 + +- 接頭辞が、コードが分岐に使う性質(採点の仕組みが無い)をそのまま表す。ID だけでダミーと分かり、判定は `startsWith('NoJudge-')` で足りる +- 数字でも小文字始まりでもないので、AOJ(数字)とも AtCoder(`abc300_a` など)とも構造上ぶつからない +- 記号を小文字にするのは、AtCoder の形式(`abc300_a`、`jag2018summer_day2_a`)に合わせるため +- `contest_id` と記号の組が一意なので、ID も一意になる +- 英数字・`-`・`_` だけなので、`/problems/{task_id}` にそのまま入る ## リンク先 -jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`)にする。21 問のコンテストすべてにあり、5 ページで済む([一覧と構成](#欠落の一覧pr3))。 +jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`)にする。 - 提出先が無いので、自分で採点するには問題文だけでなく、ジャッジデータと講評も要る。大会区分のページからは、どれにも下りられる - 深いページに着地したユーザーは、上の階層にほぼ戻らない(PDF には戻る手段すら無い)。入口のページが、ユーザーの見つけられる範囲を決める - 階層が浅いほど、下位ページ名や添付ファイル名の変更でリンクが切れる箇所も、URL 中の日本語も減る - リンク先は map の値にすぎないので、後から深くするのは定数の差し替えで済む -採らなかった案: - -| 案 | 採らない理由 | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| PDF への直リンク | 問題文までは 1 クリックだが、講評に辿れない。添付ファイル名が `day1.pdf` / `problems.pdf` と揃っておらず、切れる要因が増える | -| `問題文とデータセット` のページ | 講評に辿れない | - -## リンク先の持ち方 +## リンクの出し方 `getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータの並びに `NoJudgeGenerator` を足す。問題へのリンクはすべて `getTaskUrl()` を通るので、1 か所で全画面に効く。 @@ -607,46 +567,62 @@ const urlGenerators: UrlGenerators = [ ``` - 既存の設計(ジェネレータを並べ、最初に `canHandle` が真になったものを使う)に沿う。`getTaskUrl()` の本体は変わらず、`task.ts` の TODO(Codeforces・yukicoder・BOJ)も同じ形で足せる -- `UrlGenerator.canHandle`(`src/lib/types/url.ts`)に `taskId` を足す。使うのは `task.ts` だけで、既存の `AojGenerator` / `AtCoderGenerator` は引数が少ないままでも型を満たすので、書き換えは要らない。変更は型定義と TSDoc、`getTaskUrl()` の呼び出しの 1 行 -- **並び順に意味がある**。ダミーは JAG の `contest_id` を使うので、`NoJudgeGenerator` を `AojGenerator` より前に置かないと AOJ 側が先に受け取る。今の「AtCoder を最後に置く」と同じ種類の制約で、コメントとテスト(ダミーの `task_id` で jag-icpc.org の URL が返ること)で固定する -- 判定は出典のラベルと共通の `isNoJudgeTask()`(`startsWith('NoJudge-')`)。URL と出典のラベルで、ダミーかどうかの基準が 1 つになる +- `UrlGenerator.canHandle`(`src/lib/types/url.ts`)に `taskId` を足す。使うのは `task.ts` だけで、既存の 2 つの実装は引数が少ないままでも型を満たす +- **並び順に意味がある**。ダミーは JAG の `contest_id` を使うので、`NoJudgeGenerator` を `AojGenerator` より前に置かないと、AOJ 側が先に受け取る。コメントとテストで固定する +- 判定は出典のラベルと共通の `isNoJudgeTask()`。ダミーかどうかの基準が 1 つになる - URL は問題ごとではなくページごとに決まる(21 問に対して 5 ページ)。map は `{ url, taskIds }` をページごとに並べて組み立て、各 URL を 1 回だけ書く -- URL はブラウザで開けたものを、パーセントエンコードのまま貼る(ソースは ASCII だけになる)。部品から組み立てない(ファイル名やページ名の規則を推測すると、外れたときに黙って間違ったリンクになる)。そのまま返すことを単体テストで固定し、二重エンコードや読みやすくするための書き換えを防ぐ -- ダミーは DB、map はコードにあるので、map に漏れがあり得る。漏れたときは空文字を返し、AOJ のジェネレータには回らない(本物らしく見えて開けない AOJ のリンクにはならない)。seed の `NoJudge-` が全件 map にあることをテストで確かめて防ぐ。逆向きに、map のキーがすべて seed にあることも確かめる(キーの打ち間違いを見つける) -- 外部リンクを CI で定期的に確かめることはしない(外部サイトの状態でテストが落ちるようになる) - -採らなかった案: - -| 案 | 採らない理由 | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Task に `url` 列を足す | URL がレコードと一緒に管理できるのでデータの持ち方としては素直だが、マイグレーションが要り、`getTaskUrl()` の呼び出し元 9 ファイルに URL を渡す変更が波及する。クロスレビューの対象にもなる。21 件には重い。他サイト(`task.ts` の TODO にある Codeforces など)に対応するときに再検討する | -| `getTaskUrl()` の先頭で map を引く | コードは 2〜3 行で済むが、`getTaskUrl()` に特別扱いが入り、例外が増えるたびに先頭の分岐が増える。判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が 2 つに割れる。map に漏れがあると AOJ のジェネレータに回り、開けない AOJ のリンクになる。当初はこちらを採り、ジェネレータ案は「`canHandle` のインターフェースの変更が要る」として退けたが、実際の変更は型定義 1 行で済むと分かり、入れ替えた(2026-10-28) | +- URL はブラウザで開けたものを、パーセントエンコードのまま貼る(ソースは ASCII だけになる)。そのまま返すことを単体テストで固定し、二重エンコードや書き換えを防ぐ +- ダミーは DB、map はコードにあるので、漏れがあり得る。漏れたら空文字を返し、AOJ のジェネレータには回らない(本物らしく見えて開けない AOJ のリンクにはならない)。seed のダミーが全件 map にあること、map のキーがすべて seed にあることをテストで確かめる ## 出典のラベル -ダミーの出典は `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`。2026-10-28 に決定)。 +ダミーの出典は `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。 - 括弧の中は「どこで解けるか(どこで採点されるか)」を表す。採点の無い問題に `(AOJ)` と出すのは誤り - `task_id` の接頭辞 `NoJudge-` と言葉がそろう - 問題一覧や問題集では、アイコンを足さずに文字だけで、採点が無いことを伝えられる -- `addContestNameToTaskIndex()` は `contest_id` と記号しか受け取らない。ダミーは本物の AOJ の問題と同じ `contest_id` を使うので、`contest_id` では見分けられず、`taskId` を足す - - 省略できない引数にする。省略できる引数にすると、渡し忘れた呼び出し元が黙って `(AOJ)` のままになる。必須なら、型チェックが 6 か所の漏れをすべて見つける。呼び出し元はどこも `task_id` を持っている - - ダミーの判定は、URL のジェネレータと共通の `isNoJudgeTask()`(`startsWith('NoJudge-')`)を使う - - 括弧は今と同じ全角。テストのデータ(`src/lib/contests/fixtures/contest_name_and_task_index.ts`)にダミーの場合を足す +- `addContestNameToTaskIndex()` は `contest_id` と記号しか受け取らない。ダミーは本物の AOJ の問題と同じ `contest_id` を使うので見分けられず、`taskId` を足す +- `taskId` は省略できない引数にする。型チェックが呼び出し元 6 か所の漏れをすべて見つける。どの呼び出し元も `task_id` を持っている ## アイコンもツールチップも入れない理由 -アイコンもツールチップも入れない(2026-10-28 に決定)。採点が無いことは、出典のラベルの `(No Judge)` で文字として伝える。コンテスト別の表のセルには出典のラベルが出ないので、表の見た目は変わらない。 +採点が無いことは、出典のラベルの `(No Judge)` で文字として伝える。コンテスト別の表のセルには出典のラベルが出ないので、表の見た目は変わらない。 - 表のセルは [難易度バッジ] [問題名(長いと省略)] [状態のメニュー] で、AOJ の表は 1 行に最大 6 列。問題名はもう省略されていて、アイコンを足すとさらに削られる。外部リンクのアイコンも、表では消している(`ExternalLinkWrapper` の `iconSize={0}`) - ホバーで出る jag-icpc.org の URL だけでは気づきにくい。スマホにはホバーが無く、画面の隅の URL を見る人は少なく、ドメインが違っても提出先が無いことまでは読み取りにくい -- それでも、引っかかったときの損は小さい。リンクは新しいタブで開き、開けば提出フォームの無い Wiki だと分かる。解答状況はユーザーが自分で設定するので、ジャッジデータで自分で確かめて AC にできる +- それでも、引っかかったときの損は小さい。リンクは新しいタブで開き、開けば提出フォームの無い Wiki だと分かる。解答状況はユーザーが自分で設定するので、ジャッジデータで確かめて AC にできる + +## 影響範囲 + +変更が要る機能は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ。ほかは Task のレコードを足せば動く。 + +| 機能・処理 | 何で決まるか | ダミーへの影響 | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| コンテスト別の表 | `contest_id` で絞り、`task_table_index` の順に列を並べて置く(`sortAojHeaderIds`、`generateTable`)。`task_id` は使わない | 変更なし。7 コンテストとも表の設定(`aoj_jag_regional_entries.ts`)がある | +| セルの表示名 | `task_table_index` を付けて「A. Monkey Business」と出す。title の先頭の「記号. 」を外してから付けるが、21 問の title はどれも「記号. 」で始まらない | 変更なし | +| 問題へのリンク | すべて `getTaskUrl()` を通る(呼び出し元 9 ファイル) | `NoJudgeGenerator` を足す | +| 出典のラベル | `addContestNameToTaskIndex(contestId, taskTableIndex)` が `contest_id` だけを見て、AOJ 系なら `(AOJ)` を付ける。呼び出し元は 6 ファイル(各 1 か所) | `taskId` を足し、ダミーは `(No Judge)` にする | +| 投票・タグ・問題集・回答 | `task_id` への外部キー(6 テーブル) | 変更なし。Task があれば動く | +| 問題の詳細ページ | `/problems/{task_id}` | 変更なし | +| 一覧の並び | 画面では `compareByContestIdAndTaskId()` がコンテストの優先度 → `contest_id` → 記号の順に並べ直す。DB から取る順(`task_id` の降順)は効かない | 変更なし | +| 入力チェック | zod は `task_id` が空でないことだけを見る | 変更なし | +| 問題 ID を数値として読む箇所 | コードでは `assignProblemLetters()` の `Number()` だけ。管理画面の AOJ 取り込みと、`seed_problem_letters.test.ts` の基本ルールの検査で使う。ほかに PR1 / PR2 のマイグレーション(`20261024033928`、`20261025000719`)が `ORDER BY task_id::int` で並べる | 取り込みはダミーを通らない。テストでは、ダミーのあるコンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れて検査から外す。マイグレーションは本番・staging とも適用済みで、新しい DB では seed より先に流れるので、ダミーの行を読むことはない | +| `task_id` を `_` で分ける箇所 | `getCourseName()` だけ。AOJ の API の応答にしか使わない | 変更なし | +| 解答状況 | ユーザーが自分で設定する。AOJ から提出結果を取り込む処理は無い | ジャッジデータで確かめて AC にできる | +| 管理画面の AOJ 取り込み | Task を削除しない([5 章](#5-task-テーブルへの書き込み経路の全数調査))。すでにある行は飛ばす | ダミーは消えない。欠けた問題が AOJ に後から足されたときだけ注意する([下記](#aoj-に後から追加されたとき)) | + +レコードの入れ方: + +- seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から決める(ダミーの 7 コンテストは `AOJ_JAG`) +- staging / 本番: メンテナが Supabase の CSV で入れる。列と確認の SQL は [plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ) + +## AOJ に後から追加されたとき + +3643 のように、AOJ は過去の問題を後から追加することがある。ダミーの問題が AOJ に入ったら、AOJ の ID に移す。 -採らなかった案: +1. **管理画面から取り込まない**。取り込みは、AOJ のコンテスト一覧にある問題 ID の昇順で記号を振る(`assignProblemLetters()`)。すでにある行は飛ばすので今の記号は変わらないが、欠けたコンテストに後から足された問題には、ずれた記号が付く(例: 模擬地区 2005 の C が新しい ID で入ると、7 番目の G になる)。同じコンテストで記号が重なると、`generateTable` が片方を上書きし、問題が黙って表から消える(`(contest_id, task_table_index)` に一意制約は無い)。この危険は PR3 の前からあり、対象は管理画面から取り込める模擬地区(ダミーがあるのは 2005 / 2008)。合宿系は AOJ の一覧に無いので取り込めない +2. DB の `task_id` を AOJ の ID に UPDATE する。参照する外部キーは 6 つとも `ON UPDATE CASCADE`(taskanswer / tasktag / workbooktask / votegrade / votedgradecounter / votedgradestatistics)なので、回答・投票・タグ・問題集のデータは付いてくる。記号もそのまま残る +3. `no_judge.ts` の map から消し、seed の行の `id` と `seed_problem_letters.test.ts` の期待値を直す +4. 出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る -| 案 | 採らない理由 | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| セルにアイコンを足す | セルの幅が足りない | -| リンクに `title` 属性のツールチップ | 幅は使わないが、スマホでは出ない。`ExternalLinkWrapper` に引数を足す必要がある。知らせる仕組みが必要になったら、最初に検討する | -| 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので、意味が混ざる。どの問題が対象かも分からない | -| 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | +移し忘れても、`NoJudge-` が残っていれば一目で分かる。 diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index a15068688..b8134fb8e 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -1,57 +1,71 @@ # PR3 — 欠落問題の補完 -前提と共通の決定は [plan.md](./plan.md)。欠落の一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 +前提と共通の決定は [plan.md](./plan.md)。調べた結果と根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。採らなかった案は、すべて[下の節](#採らなかった案)にまとめた。 -**状態**: 調査(3-0)は完了、3-1 / 3-2 は設計まで決定(いずれも 2026-10-28)。3-1 と 3-2 は 1 つの PR で実装する。[実装計画](#実装計画)は承認待ち。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 +**状態**: 設計は決定(2026-10-28)。[実装計画](#実装計画)は承認待ち。 -## 欠落の 2 種類 +## 概要 -| 種類 | 例 | 扱い | -| ---------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| AOJ に問題はあるが、コンテスト一覧に無い | ICPC 国内予選 2002 の E = AOJ 1123(AOJ の Sources > ICPC - Prelim では 2002 年の問題) | 実在の ID で通常の問題として登録できる。管理画面の取り込みは一覧を使うので取り込めず、seed と本番への INSERT が要る | -| AOJ に問題そのものが無い | スプレッドシートで「AOJ 未収録」のもの(ただし下記の 3643 のように、AOJ にある場合もある) | ID が無いので、ダミー Task レコードの設計が要る | +AOJ の JAG / ICPC のコンテストで、公式の問題なのに seed に無い 26 問を足す。PR2 で今ある問題の記号を補正済みなので、足しても今ある記号は変わらない。 -## 分かっている欠落 +| 種類 | 問題 | 入れ方 | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| AOJ にあるが、取り込み元のコンテスト一覧に無い(5 問) | 1123(ICPC 国内予選 2002 の E)、3643(JAG 夏合宿 2024 Day3 の G)、2565〜2567(JAG 模擬地区 2013 の A〜C) | AOJ の問題 ID のまま、通常の問題として入れる | +| AOJ に無い(21 問) | 2005〜2008 年の JAG の 7 コンテスト([一覧](./investigation.md#aoj-に無い-21-問)) | ダミーの Task として入れ、リンクは jag-icpc.org に向ける | -| 問題 | 分かっていること | 種類 | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | -| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ(2026-10-24 にメンテナが確認)。2026-10-10 に AOJ に追加されたばかりで、問題一覧 API では `available` が 4(2565〜2567 は 1)、提出が 0(2026-10-28 に確認) | 前者(公開前の可能性) | -| JAG 模擬地区 2013 の A〜C | AOJ の問題一覧 API に 2565 / 2566 / 2567 として存在し、問題名がスプレッドシートと一致する。コンテスト一覧(`challenges/cl/jag/regional`)に無いだけで、D〜J(2568〜2574)と ID が連番(2026-10-28 にメンテナが確認) | 前者 | -| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 問題一覧 API で問題名を部分一致で検索したが、ID が見つからない(2026-10-28 にメンテナが確認) | 後者 | +管理画面の取り込みは AOJ のコンテスト一覧を使うので、どちらも取り込めない。seed に足し、staging / 本番にはメンテナが Supabase の CSV で入れる。 -3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。2565〜2567 と 3643 はどちらもコンテスト一覧に無いが、問題一覧 API には載っていた。3-0 では 21 問すべてを問題一覧 API で問題名から確かめ、いずれも見つからなかった。AOJ は過去の問題を後から追加する(3643 は 2026-10-10)ので、この結果は 2026-10-28 時点のもの。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 +## 決めたこと -## タスク +### AOJ にある 5 問 -| # | 内容 | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 3-0 | 「AOJ 未収録」21 問について、AOJ の問題一覧 API に問題名で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する。見つかった問題は `available` と提出数も記録する。**完了**: 前者 5 問(1123、3643、2565〜2567)、後者 21 問。後者はすべて 2005〜2008 年のコンテストで、冬合宿 2006 の Day2 / Day3 に 13 問が集中する | -| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT)。判明分は 1123、3643、2565〜2567 | -| 3-2 | AOJ に無い問題(21 問)のダミー Task レコードの設計 → 実装。設計は決定(下記) | +- AOJ の問題 ID と、公式の記号で入れる +- 3643 は 2026-10-10 に AOJ に追加されたばかりで、公開前かもしれない(`available` が 4、提出が 0)が、今回入れる -3-1 で決めたこと(2026-10-28): +### AOJ に無い 21 問(ダミー) -- **対象は 5 問すべて**(1123、3643、2565〜2567)。通常の AOJ の問題として、問題 ID のまま seed と staging / 本番に入れる -- **3643 も今回入れる**。2026-10-10 に AOJ に追加されたばかりで、`available` が 4、提出が 0。公開前なら、しばらく AOJ で提出できない可能性がある(`available` の値の意味は未確認)。不採用: AOJ のコンテスト一覧に入るのを待ち、管理画面の取り込みで入れる +- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま +- **title**: jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記。`Wireing Assistant` も原文どおり +- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる +- **リンクの出し方**: `getTaskUrl()` の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。URL は `task_id` をキーにした map から引く +- **出典のラベル**: `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`) +- **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルで伝える +- **出す場所**: コンテスト別の表・問題一覧・投票・タグ・問題集のすべて。Task のレコードがあれば動く +- **AOJ に後から追加されたら**: 管理画面から取り込まず、DB の `task_id` を AOJ の ID に UPDATE する([手順](./investigation.md#aoj-に後から追加されたとき)) -3-2 で決めたこと(2026-10-28)。根拠、採らなかった案の詳細、影響範囲は [investigation.md の 7 章](./investigation.md#7-pr3-のダミーの設計と影響範囲)。 +変更が要る機能は、問題へのリンクと出典のラベルの 2 つ。影響範囲の全体は [investigation.md](./investigation.md#影響範囲)。 -- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。不採用: 数字(100000 番台、50xx 番台)、`Missing-` などの接頭辞 -- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる。不採用: PDF への直リンク、`問題文とデータセット` のページ -- **リンク先の持ち方**: `getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。`UrlGenerator.canHandle` に `taskId` を足す(既存の 2 つの実装は書き換え不要)。判定は出典のラベルと共通の `isNoJudgeTask()`、URL は `task_id` をキーにした map から引く。map は `{ url, taskIds }` をページごとに並べて組み立てる。URL はブラウザで開けたものをパーセントエンコードのまま貼る。テストで、URL をそのまま返すこと、ジェネレータの並び順、seed の `NoJudge-` が全件 map にあること、map のキーがすべて seed にあることを固定する。不採用: `getTaskUrl()` の先頭で map を引く、Task に `url` 列を足す -- **出典のラベル**: ダミーは `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。汎用関数 `addContestNameToTaskIndex()`(`src/lib/contests/utils/task_index_label.ts`)に `taskId` を省略できない引数として足し、呼び出し元 6 ファイル(各 1 か所)で渡す -- **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルの文字で伝える -- **投票・タグ・問題集・コンテスト別の表**: すべてに出す。Task のレコードを足せば動く。変更が要る機能は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ(新しいモジュール `no_judge.ts` と型 `UrlGenerator` の変更を含む。[実装計画](#実装計画)) -- **AOJ に後から追加されたら**: 管理画面からは取り込まない(取り込みは AOJ の一覧の ID 順で記号を振るので、欠けたコンテストではずれた記号が付き、今の記号と重なる。[investigation.md](./investigation.md#aoj-に後から追加された場合))。代わりに DB の `task_id` を AOJ の ID に UPDATE し(参照する外部キーはすべて `ON UPDATE CASCADE`。記号はそのまま残る)、map のエントリを消す。seed(`prisma/tasks.ts`)の行の `id` と、`seed_problem_letters.test.ts` の期待値も直す。出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る +## 採らなかった案 + +| 対象 | 採らなかった案 | 理由 | +| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 3643 の入れ方 | AOJ のコンテスト一覧に入るのを待ち、管理画面で取り込む | 合宿系は管理画面の取り込みの対象外なので、一覧に入っても取り込めない | +| ダミーの title | 一覧でしか確かめていない 19 問を、問題文の PDF の表紙でも確かめる | 一覧はスプレッドシートとも一致していて、誤記が残る見込みは小さい | +| ダミーの `task_id` | 数字(100000 番台など) | 既存のコードは壊れないが、AOJ の本物の ID と見分けられない。数字の範囲は AOJ が決める領域(通常の問題は 40xx 番台まで、AOJ 自身のダミーに 9900〜9995)で、衝突の心配が残る | +| ダミーの `task_id` | 50xx 番台 | AOJ の通常の採番がいずれ届く | +| ダミーの `task_id` | `Missing-` | 「欠けている」は AOJ にある 5 問にも当てはまり、意味がずれる。問題文はあるのに、中身の無い仮のレコードやエラーページにも読める | +| ダミーの `task_id` | `jag-icpc_` など出所を表す接頭辞 | 分岐の理由(採点が無い)ではなく、問題文の置き場所を表す | +| リンク先 | PDF への直リンク | 問題文までは 1 クリックだが、講評に辿れない。添付ファイル名が揃っておらず、切れる要因が増える | +| リンク先 | `問題文とデータセット` のページ | 講評に辿れない | +| リンクの出し方 | `getTaskUrl()` の先頭で map を引く | コードは 2〜3 行で済むが、`getTaskUrl()` に特別扱いが入る。判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が割れる。map に漏れがあると AOJ のジェネレータに回り、開けない AOJ のリンクになる | +| リンクの出し方 | Task に `url` 列を足す | マイグレーションが要り、`getTaskUrl()` の呼び出し元 9 ファイルに URL を渡す変更が波及する。21 件には重い。他サイトに対応するときに再検討する | +| リンクの出し方 | URL を年・大会区分・ファイル名から組み立てる | 規則の推測が外れると、黙って間違ったリンクになる。ブラウザで開けた URL をそのまま貼る | +| リンクの出し方 | map を隠し、URL を取り出す関数だけを export する | seed との突き合わせのテストで、map のキーを列挙できない | +| リンクの出し方 | 外部リンクを CI で定期的に確かめる | 外部サイトの状態でテストが落ちるようになる | +| 判定の置き場所 | `isNoJudgeTask()` を `src/lib/utils/` に置く | `src/lib/utils/task.ts` が `#lib/contests/index.js` を import しているので、`lib/contests` の `task_index_label.ts` から使うと循環 import になる | +| 出典のラベル | `taskId` を省略できる引数にする | 渡し忘れた呼び出し元が、黙って `(AOJ)` のままになる。必須なら、型チェックが漏れを見つける | +| 見た目 | セルにアイコンを足す | セルの幅が足りない(AOJ の表は 1 行に最大 6 列で、問題名はもう省略されている) | +| 見た目 | リンクに `title` 属性のツールチップ | 幅は使わないが、スマホでは出ない。知らせる仕組みが必要になったら、最初に検討する | +| 見た目 | 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので意味が混ざり、どの問題が対象かも分からない | +| 見た目 | 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | ## 実装計画 -3-1(5 問)と 3-2(21 問)を 1 つの PR で入れる。リスクの低い順に 5 段階。各段階はテストを先に書く。 +1 つの PR で入れる。リスクの低い順に 5 段階で、各段階はテストを先に書く。 ### 段階 1: ダミーの判定と URL(新しいモジュール。既存のコードには影響しない) -`src/lib/contests/utils/no_judge.ts` を新しく作り、`src/lib/contests/index.ts` から export する。 +本体を `src/lib/contests/utils/no_judge.ts` に置き、外から使うものを `src/lib/contests/index.ts` から再 export する。 ```typescript export const NO_JUDGE_TASK_ID_PREFIX = 'NoJudge-'; @@ -62,18 +76,18 @@ export function isNoJudgeTask(taskId: string): boolean; export const NO_JUDGE_TASK_URLS: ReadonlyMap; ``` -- `lib/contests` に置く理由: `src/lib/utils/task.ts` は `#lib/contests/index.js` を import している。判定を `lib/utils` に置くと、`task_index_label.ts`(`lib/contests`)からの import で循環する。`lib/contests` は `lib/utils` に依存していない -- URL の map は、同じファイルの中で `{ url, taskIds }` を 5 ページ分並べた配列(こちらは外に出さない)から組み立て、読み取り専用の `NO_JUDGE_TASK_URLS` として export する。取り出し用の関数は作らない(段階 4 のテストで seed と突き合わせるのにキーの列挙が要る) +- map は、5 ページ分の `{ url, taskIds }` の配列(外には出さない)から組み立てる +- URL はブラウザで開けたものを、パーセントエンコードのまま貼る - テスト(`src/lib/contests/utils/no_judge.test.ts`): - `returns true for task ids starting with NoJudge-` - `returns false for AOJ and AtCoder task ids`(`'1123'`、`'abc300_a'`、小文字の `'nojudge-...'`) - - `maps each NoJudge task to its jag-icpc.org page url verbatim`(5 ページそれぞれ `get` で引く。期待値は URL のリテラル) + - `maps each NoJudge task to its jag-icpc.org page url verbatim`(5 ページそれぞれ。期待値は URL のリテラル) - `has no entry for AOJ task ids` ### 段階 2: URL ジェネレータ(共有の型を変えるが、既存の実装は書き換えない) -- `src/lib/types/url.ts`: `canHandle(contestId: string, taskId: string): boolean` に広げ、TSDoc も直す -- `src/lib/utils/task.ts`: `NoJudgeGenerator` を足して `AojGenerator` より前に置き、`getTaskUrl()` の呼び出しで `taskId` を渡す。`canHandle` は `isNoJudgeTask(taskId)`、`generateUrl` は `NO_JUDGE_TASK_URLS.get(taskId) ?? ''` を返す(map に漏れがあっても AOJ のジェネレータには回さない)。データは持たない +- `src/lib/types/url.ts`: `canHandle(contestId: string, taskId: string): boolean` に広げ、TSDoc も直す。既存の `AojGenerator` / `AtCoderGenerator` は引数が少ないままでも型を満たす +- `src/lib/utils/task.ts`: `NoJudgeGenerator` を足して `AojGenerator` より前に置き、`getTaskUrl()` から `taskId` も渡す。`canHandle` は `isNoJudgeTask(taskId)`、`generateUrl` は `NO_JUDGE_TASK_URLS.get(taskId) ?? ''` を返す(map に漏れがあっても AOJ のジェネレータには回さない) - テスト(`src/test/lib/utils/test_cases/task_url.ts` に足す): - `returns the jag-icpc.org page for a NoJudge task in a JAG contest` - `returns the AOJ problem url for an AOJ task in the same JAG contest`(並び順の固定) @@ -89,37 +103,37 @@ export const addContestNameToTaskIndex = ( ``` - ダミーなら `` `${contestName} - ${taskTableIndex}(No Judge)` `` を返す -- 呼び出し元 6 ファイル(各 1 か所)で `task_id` を渡す: `TaskList.svelte`、`TaskListSorted.svelte`、`TaskForm.svelte`、`WorkBookTasksTable.svelte`、`src/routes/workbooks/[slug]/+page.svelte`、`TaskGradeTable.svelte`。漏れは型チェック(`pnpm check`)が見つける +- `taskId` は省略できない引数にする。呼び出し元 6 ファイル(各 1 か所)で `task_id` を渡し、漏れは `pnpm check` が見つける: `TaskList.svelte`、`TaskListSorted.svelte`、`TaskForm.svelte`、`WorkBookTasksTable.svelte`、`src/routes/workbooks/[slug]/+page.svelte`、`TaskGradeTable.svelte` - テスト: `src/lib/contests/fixtures/contest_name_and_task_index.ts` の型に `taskId` を足して既存のケースを直し、ダミーのケースを足す(`appends (No Judge) for a NoJudge task in a JAG contest`) ### 段階 4: seed のデータ(26 行) -`prisma/tasks.ts` に 26 行を足す。`name` と `title` は同じ値にし、21 問は jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記どおりにする([investigation.md](./investigation.md#欠落の一覧pr3))。問題文の PDF の表紙で確かめたのは 2 問だけだが、残る 19 問も一覧の表記を使う(2026-10-28 に決定)。 +`prisma/tasks.ts` に 26 行を足す。`name` と `title` は同じ値にする。 -| 種類 | `id` | `contest_id` と記号 | -| ---- | ------------------------------------- | --------------------------- | -| 3-1 | `1123` | `ICPCPrelim2002` E | -| 3-1 | `3643` | `JAGSummer2024-day3` G | -| 3-1 | `2565` / `2566` / `2567` | `JAGRegional2013` A / B / C | -| 3-2 | `NoJudge-{contest_id}_{記号の小文字}` | 7 コンテストの 21 問 | +| 種類 | `id` | `contest_id` と記号 | +| ---------- | ------------------------------------- | ------------------------------------------------------------------- | +| AOJ にある | `1123` | `ICPCPrelim2002` E | +| AOJ にある | `3643` | `JAGSummer2024-day3` G | +| AOJ にある | `2565` / `2566` / `2567` | `JAGRegional2013` A / B / C | +| ダミー | `NoJudge-{contest_id}_{記号の小文字}` | 7 コンテストの 21 問([一覧](./investigation.md#aoj-に無い-21-問)) | 先にテストの期待値を直す: - `src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts` - - 件数を 151 コンテスト・1,329 問から 151 コンテスト・1,355 問にする(コンテストは増えない) - - `LETTERS_OF_SHIFTED_CONTESTS`: ダミーのある 7 コンテストのうち、すでに載っている 6 コンテストにはダミーの ID と記号を足し、載っていない `JAGRegional2008` は新しく足す。`assignProblemLetters()` は ID を `Number()` で並べるので、`NoJudge-` を含むコンテストは基本ルールの検査に通せない - - `JAGRegional2013`(2565〜2574)と `JAGSummer2024-day3`(3637〜3647)は、足すと ID が連番になって基本ルールに戻るので、`LETTERS_OF_SHIFTED_CONTESTS` から外す。`ICPCPrelim2002` は 1119〜1123 で基本ルールのまま - - テストを足す: `every contest with NoJudge tasks is in LETTERS_OF_SHIFTED_CONTESTS`。ダミーを足して一覧に入れ忘れると、基本ルールの検査が `NaN` を含む並べ替えになり、落ちるとは限らないため - - `LETTERS_OF_SHIFTED_CONTESTS` の上のコメント(「AOJ に無い問題があると基本ルールがずれる」)に、`NoJudge-` の ID は `Number()` で並べられないことも書き足す + - 件数を 1,329 問から 1,355 問にする(151 コンテストは変わらない) + - `LETTERS_OF_SHIFTED_CONTESTS`: ダミーのある 7 コンテストを載せる。6 つは既にあるのでダミーの ID と記号を足し、`JAGRegional2008` は新しく足す。`assignProblemLetters()` は ID を `Number()` で並べるので、`NoJudge-` を含むコンテストは基本ルールの検査に通せない + - `JAGRegional2013`(2565〜2574)と `JAGSummer2024-day3`(3637〜3647)は、ID が連番になって基本ルールに戻るので、`LETTERS_OF_SHIFTED_CONTESTS` から外す。`ICPCPrelim2002`(1119〜1123)は基本ルールのまま + - テストを足す: `every contest with NoJudge tasks is in LETTERS_OF_SHIFTED_CONTESTS`(入れ忘れると基本ルールの検査が `NaN` を含む並べ替えになり、落ちるとは限らない) + - `LETTERS_OF_SHIFTED_CONTESTS` の上のコメントに、`NoJudge-` の ID は `Number()` で並べられないことを書き足す - `src/lib/contests/utils/no_judge.test.ts` に seed との突き合わせを足す - - `every NoJudge task in prisma/tasks.ts has a page url`(`NO_JUDGE_TASK_URLS.has`) - - `every task id with a page url exists in prisma/tasks.ts`(`NO_JUDGE_TASK_URLS.keys()`。map のキーの打ち間違いを見つける) + - `every NoJudge task in prisma/tasks.ts has a page url` + - `every task id with a page url exists in prisma/tasks.ts`(map のキーの打ち間違いを見つける) -### 段階 5: 投入用の行の一覧と仕上げ +### 段階 5: 仕上げ -- メンテナが CSV を作るための 26 行の一覧(`task_id`、`contest_id`、`contest_type`、`task_table_index`、`title`)を、下の「[staging / 本番への投入](#staging--本番への投入メンテナ)」に足す。`contest_type` は `ICPCPrelim2002`(1123)だけ `AOJ_ICPC`、ほかの 25 行は `AOJ_JAG`。`id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする -- ガイドとスキルを、AOJ に無い問題をダミーで入れる手順に合わせて直す(`docs/guides/agent-rules/` のルールは変更不要) - - `.agents/skills/add-tasks/instructions.md`(Collect rows): 今は「最後より前の問題が AOJ に無ければ、記号をスプレッドシートから取り `LETTERS_OF_SHIFTED_CONTESTS` に足す」とだけある。AOJ に無い問題は、末尾でも `NoJudge-{contest_id}_{記号の小文字}` の行で入れ、`no_judge.ts` に jag-icpc.org のページを登録し、コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に足す、と書く。「`id` を contest_id から導かない」の例外であることも明記する。AOJ にあるがコンテスト一覧に無い問題は、問題 ID のまま入れる。Tests の `contest_name_and_task_index.ts` は `taskId` が必須になる +- CSV 用の 26 行の一覧(`task_id`、`contest_id`、`contest_type`、`task_table_index`、`title`)を、下の「[staging / 本番への投入](#staging--本番への投入メンテナ)」に足す。`contest_type` は 1123 だけ `AOJ_ICPC`、ほかの 25 行は `AOJ_JAG` +- ガイドとスキルに、AOJ に無い問題をダミーで入れる手順を書く + - `.agents/skills/add-tasks/instructions.md`(Collect rows): AOJ に無い問題は末尾でも `NoJudge-` の行で入れ、`no_judge.ts` にページを登録し、コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に足す。「`id` を contest_id から導かない」の例外であることも書く。AOJ にあるがコンテスト一覧に無い問題は、問題 ID のまま入れる。`contest_name_and_task_index.ts` は `taskId` が必須になる - `docs/guides/how-to-add-contest-table-provider.md`(「JAG 模擬地区は AOJ と AtCoder が混在する」の「記号」): 同じ手順を 1 行で足す - `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`git diff --check` - 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と出典のラベルを目で確かめる @@ -127,7 +141,12 @@ export const addContestNameToTaskIndex = ( ### staging / 本番への投入(メンテナ) -コードのデプロイの後に CSV を入れる。先に入れると、デプロイまでの間、ダミーが AOJ の開けないリンクと `(AOJ)` のラベルで表示される。Supabase の CSV は Prisma の既定値を使わないので、`id`・`contest_type`・`created_at`・`updated_at` を明示する(`docs/guides/agent-rules/prisma-db.md`。`created_at` は DB に既定値があっても、CSV の取り込みで空になった例がある)。行の一覧は実装の段階 5 でここに足す。 +コードのデプロイの後に CSV を入れる(先に入れると、デプロイまでの間、ダミーが開けない AOJ のリンクと `(AOJ)` のラベルで出る)。 + +- 列は `id`・`contest_type`・`created_at`・`updated_at` も明示する。Supabase の CSV は Prisma の既定値を使わず、`created_at` は DB に既定値があっても空になった例がある(`docs/guides/agent-rules/prisma-db.md`) +- `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする +- CSV の投入は `invalidateTaskCaches()` を経ないので、画面に出るのはタスクのキャッシュの有効期限(1 時間)が切れた後 +- 行の一覧は段階 5 でここに足す CSV を入れたら、次の SQL で記号を確かめる。`(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える。seed はテストが守るが、CSV は守らない。 From 90c736c05bf4e896850cfd6c4768ae7e2153b3bc Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 08:47:38 +0000 Subject: [PATCH 07/15] docs: add task index letter stability analysis for PR3 Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index d4dd0f3cd..3ccca8a1d 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -508,9 +508,23 @@ JAG のサイトで、スプレッドシートの情報源の一つ。URL の形 - 添付ファイル名は揃っていない(2006 年夏合宿は `day1.pdf` など日ごと、2008 年模擬地区予選は `problems.pdf`) - 年度ページの問題一覧は手書きで、誤記が混じる(例: PDF の表紙と AOJ が `Flame of Nucleus` の問題を、一覧は `Frame of Nucleus` と書いている)。表記が食い違ったら、問題文の PDF の表紙で判断する -## コンテスト別の表での記号 +## 記号がずれないか -今ある記号(PR2 で補正)は、年度ページの問題一覧とも一致した(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005)。ダミーは空いている位置にちょうど収まる。 +PR3 で記号(`task_table_index`)がずれる心配は、事実上ない。 + +- **今ある記号は変わらない**。各行に保存された値で、PR2 で補正済み(本番・staging とも反映済み)。PR3 は行を足すだけで、書き換えない。画面も保存された値を使い、問題 ID から計算し直さない。PR1 / PR2 のマイグレーションが再び流れることもない([影響範囲](#影響範囲)) +- **足す 26 行の記号は、2 つ以上の根拠がそろう**。ダミーの 21 問は、スプレッドシートの位置と jag-icpc.org(年度ページの問題一覧で 5 コンテスト、問題文の PDF の表紙で 2 コンテスト)。AOJ にある 5 問は、スプレッドシートの位置と AOJ の ID の並び(1119〜1123 の E、3642 と 3644 の間の G、2565〜2574 の A〜C) +- **空いている位置にちょうど収まる**(下表)。今ある記号も、年度ページの問題一覧と一致した(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005) +- **seed はテストが固定する**。`seed_problem_letters.test.ts` が記号を 1 問ずつ確かめる([plan-pr3.md の段階 4](./plan-pr3.md#段階-4-seed-のデータ26-行)) + +残るのは人の作業の誤りで、どちらも確かめる手順がある: + +| リスク | 起きる条件 | 守り | +| -------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 本番の CSV の打ち間違い | メンテナが作る CSV の記号を間違える | 投入後の SQL で、記号の重なりと 10 コンテストの記号を確かめる([plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ))。見つかっても UPDATE 1 本で直る | +| 管理画面の取り込みで、ずれた記号が付く | 将来 AOJ が欠けた問題を足し、それを管理画面から取り込む(PR3 の前からある危険) | 取り込まず、ダミーの `task_id` を UPDATE する([手順](#aoj-に後から追加されたとき)) | + +ダミーで足す記号: | コンテスト | 今ある記号 | ダミーで足す記号 | 合わせると | | ------------------ | ---------------- | ------------------- | ---------- | @@ -522,7 +536,7 @@ JAG のサイトで、スプレッドシートの情報源の一つ。URL の形 | JAGRegional2005 | A, B, D, E, G, I | C, F, H | A〜I | | JAGRegional2008 | A〜I | J | A〜J | -AOJ にある 5 問も、空いている位置に入る(ICPC 国内予選 2002 は A〜E、模擬地区 2013 は A〜J、夏合宿 2024 Day3 は A〜K になる)。 +AOJ にある 5 問を足すと、ICPC 国内予選 2002 は A〜E、模擬地区 2013 は A〜J、夏合宿 2024 Day3 は A〜K になる。 ## ダミーの `task_id` From 51c82bf3787d20f4799bb44cbe330c3c05023d5d Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 09:10:36 +0000 Subject: [PATCH 08/15] feat: add NoJudge dummy tasks for problems missing from AOJ Add 26 seed rows (21 NoJudge dummies linked to jag-icpc.org pages, 5 AOJ problems missing from contest lists), a NoJudge URL generator, and a NoJudge source label in addContestNameToTaskIndex. Co-Authored-By: Claude Sonnet 5.5 --- .agents/skills/add-tasks/instructions.md | 2 + .../task-title-index-change-impact/done.md | 43 ++++- .../plan-pr3.md | 112 ++++------- .../task-title-index-change-impact/plan.md | 60 +++--- .../how-to-add-contest-table-provider.md | 1 + prisma/tasks.ts | 182 ++++++++++++++++++ .../detail/WorkBookTasksTable.svelte | 1 + .../seed_problem_letters.test.ts | 83 +++++--- src/lib/components/TaskForm.svelte | 2 +- src/lib/components/TaskList.svelte | 6 +- src/lib/components/TaskListSorted.svelte | 6 +- .../fixtures/contest_name_and_task_index.ts | 18 ++ src/lib/contests/index.ts | 3 + src/lib/contests/utils/no_judge.test.ts | 92 +++++++++ src/lib/contests/utils/no_judge.ts | 58 ++++++ .../contests/utils/task_index_label.test.ts | 117 +++++++---- src/lib/contests/utils/task_index_label.ts | 16 +- src/lib/types/url.ts | 5 +- src/lib/utils/task.ts | 22 ++- .../grade/_components/TaskGradeTable.svelte | 2 +- src/routes/workbooks/[slug]/+page.svelte | 2 +- src/test/lib/utils/task.test.ts | 8 + src/test/lib/utils/test_cases/task_url.ts | 14 ++ 23 files changed, 673 insertions(+), 182 deletions(-) create mode 100644 src/lib/contests/utils/no_judge.test.ts create mode 100644 src/lib/contests/utils/no_judge.ts diff --git a/.agents/skills/add-tasks/instructions.md b/.agents/skills/add-tasks/instructions.md index eaf319cbb..7fb7808a5 100644 --- a/.agents/skills/add-tasks/instructions.md +++ b/.agents/skills/add-tasks/instructions.md @@ -18,6 +18,8 @@ - atcoder.jp is unreachable and WebFetch truncates problems.json, so a miss there is not evidence of absence — ask the user to paste the rows - Use `id` / `problem_index` / `name` / `title` verbatim; do not derive `id` from contest_id - AOJ ICPC and every AOJ JAG contest (Prelim, Regional, training camps): set `problem_index` to the problem letter, not the AOJ problem id — A, B, C... in ascending problem id within the contest, as `assignProblemLetters` does on import. When a problem before the last one is missing from AOJ, take the letters from the maintainer's problem list and add the contest to `LETTERS_OF_SHIFTED_CONTESTS`. Keep `title` plain, without a `. ` prefix. `seed_problem_letters.test.ts` checks both +- A problem the maintainer's list has but AOJ lacks (even a trailing one): add it with `id` `NoJudge-{contest_id}_{lowercase letter}`, register its jag-icpc.org page in `NO_JUDGE_PAGES` (`src/lib/contests/utils/no_judge.ts`), and add the contest to `LETTERS_OF_SHIFTED_CONTESTS` — `Number()` cannot sort these ids. This is the exception to "do not derive `id` from contest_id". A problem that exists on AOJ but is missing from the contest list keeps its AOJ problem id +- `addContestNameToTaskIndex` takes `taskId` (required); `contest_name_and_task_index.ts` cases set it only for NoJudge ## Tests diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md index 130168eb0..3340f48a6 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md @@ -1,17 +1,18 @@ -# 完了分(PR0〜PR2.5)— 意思決定の要約 +# 完了分(PR0〜PR3)— 意思決定の要約 コードと git の履歴から読み取れない判断と事実だけを、項目ごとにまとめる。変更したファイルや関数は各 PR の差分を見る。全体像は [plan.md](./plan.md)、根拠データは [investigation.md](./investigation.md)。 -| PR | 内容 | 状態 | -| -------------- | --------------------------------------------------------------------- | ------------------------------ | -| PR0(#4212) | 出典欄の AOJ の書式 | 完了 | -| PR1(#4217) | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を問題記号にする | 完了 | -| PR1.5(#4219) | AOJ API の移転に追従する(#4028) | 完了 | -| PR2(#4220) | JAG 合宿系と JAG 模擬地区(66 / 625)を問題記号にする(#4030) | 完了 | -| PR2.1(#4222) | DB に無い 10 コンテストを入れる(#4221) | 完了(本番のデータも投入済み) | -| PR2.5 | 76 コンテストのテーブルを、グループ「JAG 模擬地区」に作る(#4026) | 完了 | +| PR | 内容 | 状態 | +| -------------- | --------------------------------------------------------------------- | -------------------------------- | +| PR0(#4212) | 出典欄の AOJ の書式 | 完了 | +| PR1(#4217) | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を問題記号にする | 完了 | +| PR1.5(#4219) | AOJ API の移転に追従する(#4028) | 完了 | +| PR2(#4220) | JAG 合宿系と JAG 模擬地区(66 / 625)を問題記号にする(#4030) | 完了 | +| PR2.1(#4222) | DB に無い 10 コンテストを入れる(#4221) | 完了(本番のデータも投入済み) | +| PR2.5 | 76 コンテストのテーブルを、グループ「JAG 模擬地区」に作る(#4026) | 完了 | +| PR3 | AOJ のコンテスト一覧に無い 26 問を seed に足す | 実装済み、未マージ。CSV 投入待ち | -本番・staging での確認(移行、出典欄の表示、データの投入、テーブルの目視確認)はすべて済んだ(2026-10-28 にメンテナが確認)。 +PR0〜PR2.5 の本番・staging での確認(移行、出典欄の表示、データの投入、テーブルの目視確認)はすべて済んだ(2026-10-28 にメンテナが確認)。PR3 は未マージで、staging / 本番への CSV 投入が残っている([plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ))。 ## 残っている作業 @@ -38,6 +39,12 @@ PR2 以降の計画には含めていない。 - **PR2.1**: ContestType `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテスト(`jag2018summer-day2` / `jag2017summer-day1` / `jag2015summer-day3` / `jag2013summer-warmingup`)を完全一致で分類するようにした。マイグレーションは `prisma/migrations/20261025030756_add_atcoder_jag_to_contest_type/` - seed に、この 4 コンテスト(42 問)と、2025 / 2026 の JAG 夏合宿(`JAGSummer2025-day1`〜`JAGSummer2026-day3`、AOJ 3648–3719、6 コンテスト / 72 問、記号 A–L・title は素)を足した。値はメンテナから受け取った(AtCoder 側は AtCoder Problems の値) - **PR2.5**: グループ「JAG 模擬地区」を「JAG 模擬国内」の後に足し、合宿系 50・模擬地区 16・PR2.1 の 10 の計 76 コンテストを、コンテストごとのテーブルにした。並びと括弧書きは `aoj_jag_regional_entries.ts` の配列で持つ。中身は [investigation.md の表示順](./investigation.md#表示順76-コンテスト) と同じ +- **PR3**: AOJ のコンテスト一覧に無い公式の 26 問を seed に足した。AOJ にある 5 問(1123 / 3643 / 2565〜2567)は AOJ の ID のまま、AOJ に無い 21 問は `NoJudge-{contest_id}_{小文字の記号}` のダミーにした + - `src/lib/contests/utils/no_judge.ts`: `isNoJudgeTask()` と、task_id から jag-icpc.org の大会区分ページ(計 5 ページ)を引く `NO_JUDGE_TASK_URLS` + - `UrlGenerator.canHandle` に `taskId` を足し、`NoJudgeGenerator` を `AojGenerator` の前に置いた。map に漏れがあれば空文字を返し、AOJ には回さない + - `addContestNameToTaskIndex` に必須の `taskId` を足し、ダミーの出典欄は `(No Judge)` にした。呼び出し元 6 ファイルは `pnpm check` で漏れが無いことを確かめた + - `seed_problem_letters.test.ts`: 件数を 1,355 問にし、ダミーのある 7 コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れた(`JAGRegional2008` は新規)。連番に戻った `JAGRegional2013` と `JAGSummer2024-day3` は外した。ダミーのコンテストの入れ忘れを検出するテストも足した + - `add-tasks` のスキルと `how-to-add-contest-table-provider.md` に、ダミーで入れる手順を足した ## 進め方 @@ -91,6 +98,22 @@ PR2 以降の計画には含めていない。 - **問題ページへのリンク**(PR1.5): `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける(メンテナが確認) - **staging(Vercel)での取り込み**(PR1.5): 2026-10-24 にメンテナが JAG REGIONAL を取り込み、16 / 158 になった(PR2 の前提)。こちらの取得ツールが新しいホストに `ECONNREFUSED` で拒まれたことから送信元の制限を心配していたが、Vercel からは問題なく取得できた +## 欠落問題の補完(PR3) + +| 決定 | 理由 | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| AOJ に無い問題は、`NoJudge-` 接頭辞のダミー Task にする | 分岐の理由(採点が無い)をそのまま表し、数字(AOJ の領域)とも AtCoder の形式とも衝突しない。`Missing-` は AOJ にある 5 問にも当てはまり、`jag-icpc_` は置き場所を表す | +| リンク先は jag-icpc.org の大会区分のページ。URL は組み立てず、開けたものをそのまま貼る | 問題文・ジャッジデータ・講評のどれにも下りられる。PDF への直リンクは講評に辿れず、規則の推測が外れると黙って間違ったリンクになる | +| リンクは URL ジェネレータの先頭に足し、判定は `isNoJudgeTask()` に一本化する | `getTaskUrl()` に特別扱いを入れず、出典のラベルと基準をそろえる。`isNoJudgeTask()` を `src/lib/utils/` に置くと `lib/contests` と循環 import になるので、`lib/contests` に置く | +| 出典欄は `(No Judge)`。アイコンもツールチップも付けない | 表のセルに幅が無く、スマホにはホバーが無い。引っかかっても、新しいタブで開けば提出フォームの無い Wiki だと分かり、損は小さい | +| AOJ に後から追加されたら、管理画面から取り込まず、DB の `task_id` を UPDATE する | 取り込みは欠けたコンテストに後から足された問題へずれた記号を付け、重なると表から黙って消える。外部キー 6 つは `ON UPDATE CASCADE`([手順](./investigation.md#aoj-に後から追加されたとき)) | +| 3643 は公開前かもしれないが入れる | 2026-10-10 に AOJ に追加された。合宿系は管理画面の取り込みの対象外なので、待っても取り込めない | + +分かったこと: + +- **実装中の変更**: `task_index_label.test.ts` の既存ケースが約 80 件あるので、fixture の `taskId` は省略できる項目にし、テスト側で通常の ID を補った。必須にしたのは関数の引数だけ +- **確認の手段**: seed との突き合わせのテスト 2 件(ダミーは全件 map にある・map のキーは全件 seed にある)が、map の漏れと打ち間違いを守る。CSV の記号は投入後の SQL で確かめる + ## 分類と表示 | 決定 | PR | 理由 | diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md index b8134fb8e..7bdb36c35 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -2,7 +2,7 @@ 前提と共通の決定は [plan.md](./plan.md)。調べた結果と根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。採らなかった案は、すべて[下の節](#採らなかった案)にまとめた。 -**状態**: 設計は決定(2026-10-28)。[実装計画](#実装計画)は承認待ち。 +**状態**: 実装済み、未マージ(2026-10-28。ユニットテスト・lint・check は通過)。残りは、開発サーバーでの目視、Codex のクロスレビュー、マージ後の [CSV 投入](#staging--本番への投入メンテナ)。実装の決定は [done.md](./done.md#pr3) に要約した。 ## 概要 @@ -59,85 +59,14 @@ AOJ の JAG / ICPC のコンテストで、公式の問題なのに seed に無 | 見た目 | 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので意味が混ざり、どの問題が対象かも分からない | | 見た目 | 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | -## 実装計画 +## 実装 -1 つの PR で入れる。リスクの低い順に 5 段階で、各段階はテストを先に書く。 +5 段階(ダミーの判定と URL → URL ジェネレータ → 出典のラベル → seed → 仕上げ)で、テストを先に書いて入れた。内容と計画からの変更は [done.md](./done.md#pr3)。 -### 段階 1: ダミーの判定と URL(新しいモジュール。既存のコードには影響しない) +未了: -本体を `src/lib/contests/utils/no_judge.ts` に置き、外から使うものを `src/lib/contests/index.ts` から再 export する。 - -```typescript -export const NO_JUDGE_TASK_ID_PREFIX = 'NoJudge-'; - -export function isNoJudgeTask(taskId: string): boolean; - -// Built from a private { url, taskIds }[] of the 5 jag-icpc.org pages. -export const NO_JUDGE_TASK_URLS: ReadonlyMap; -``` - -- map は、5 ページ分の `{ url, taskIds }` の配列(外には出さない)から組み立てる -- URL はブラウザで開けたものを、パーセントエンコードのまま貼る -- テスト(`src/lib/contests/utils/no_judge.test.ts`): - - `returns true for task ids starting with NoJudge-` - - `returns false for AOJ and AtCoder task ids`(`'1123'`、`'abc300_a'`、小文字の `'nojudge-...'`) - - `maps each NoJudge task to its jag-icpc.org page url verbatim`(5 ページそれぞれ。期待値は URL のリテラル) - - `has no entry for AOJ task ids` - -### 段階 2: URL ジェネレータ(共有の型を変えるが、既存の実装は書き換えない) - -- `src/lib/types/url.ts`: `canHandle(contestId: string, taskId: string): boolean` に広げ、TSDoc も直す。既存の `AojGenerator` / `AtCoderGenerator` は引数が少ないままでも型を満たす -- `src/lib/utils/task.ts`: `NoJudgeGenerator` を足して `AojGenerator` より前に置き、`getTaskUrl()` から `taskId` も渡す。`canHandle` は `isNoJudgeTask(taskId)`、`generateUrl` は `NO_JUDGE_TASK_URLS.get(taskId) ?? ''` を返す(map に漏れがあっても AOJ のジェネレータには回さない) -- テスト(`src/test/lib/utils/test_cases/task_url.ts` に足す): - - `returns the jag-icpc.org page for a NoJudge task in a JAG contest` - - `returns the AOJ problem url for an AOJ task in the same JAG contest`(並び順の固定) - -### 段階 3: 出典のラベル(汎用関数のシグネチャを変え、呼び出し元 6 ファイルに波及する) - -```typescript -export const addContestNameToTaskIndex = ( - contestId: string, - taskTableIndex: string, - taskId: string, -): string; -``` - -- ダミーなら `` `${contestName} - ${taskTableIndex}(No Judge)` `` を返す -- `taskId` は省略できない引数にする。呼び出し元 6 ファイル(各 1 か所)で `task_id` を渡し、漏れは `pnpm check` が見つける: `TaskList.svelte`、`TaskListSorted.svelte`、`TaskForm.svelte`、`WorkBookTasksTable.svelte`、`src/routes/workbooks/[slug]/+page.svelte`、`TaskGradeTable.svelte` -- テスト: `src/lib/contests/fixtures/contest_name_and_task_index.ts` の型に `taskId` を足して既存のケースを直し、ダミーのケースを足す(`appends (No Judge) for a NoJudge task in a JAG contest`) - -### 段階 4: seed のデータ(26 行) - -`prisma/tasks.ts` に 26 行を足す。`name` と `title` は同じ値にする。 - -| 種類 | `id` | `contest_id` と記号 | -| ---------- | ------------------------------------- | ------------------------------------------------------------------- | -| AOJ にある | `1123` | `ICPCPrelim2002` E | -| AOJ にある | `3643` | `JAGSummer2024-day3` G | -| AOJ にある | `2565` / `2566` / `2567` | `JAGRegional2013` A / B / C | -| ダミー | `NoJudge-{contest_id}_{記号の小文字}` | 7 コンテストの 21 問([一覧](./investigation.md#aoj-に無い-21-問)) | - -先にテストの期待値を直す: - -- `src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts` - - 件数を 1,329 問から 1,355 問にする(151 コンテストは変わらない) - - `LETTERS_OF_SHIFTED_CONTESTS`: ダミーのある 7 コンテストを載せる。6 つは既にあるのでダミーの ID と記号を足し、`JAGRegional2008` は新しく足す。`assignProblemLetters()` は ID を `Number()` で並べるので、`NoJudge-` を含むコンテストは基本ルールの検査に通せない - - `JAGRegional2013`(2565〜2574)と `JAGSummer2024-day3`(3637〜3647)は、ID が連番になって基本ルールに戻るので、`LETTERS_OF_SHIFTED_CONTESTS` から外す。`ICPCPrelim2002`(1119〜1123)は基本ルールのまま - - テストを足す: `every contest with NoJudge tasks is in LETTERS_OF_SHIFTED_CONTESTS`(入れ忘れると基本ルールの検査が `NaN` を含む並べ替えになり、落ちるとは限らない) - - `LETTERS_OF_SHIFTED_CONTESTS` の上のコメントに、`NoJudge-` の ID は `Number()` で並べられないことを書き足す -- `src/lib/contests/utils/no_judge.test.ts` に seed との突き合わせを足す - - `every NoJudge task in prisma/tasks.ts has a page url` - - `every task id with a page url exists in prisma/tasks.ts`(map のキーの打ち間違いを見つける) - -### 段階 5: 仕上げ - -- CSV 用の 26 行の一覧(`task_id`、`contest_id`、`contest_type`、`task_table_index`、`title`)を、下の「[staging / 本番への投入](#staging--本番への投入メンテナ)」に足す。`contest_type` は 1123 だけ `AOJ_ICPC`、ほかの 25 行は `AOJ_JAG` -- ガイドとスキルに、AOJ に無い問題をダミーで入れる手順を書く - - `.agents/skills/add-tasks/instructions.md`(Collect rows): AOJ に無い問題は末尾でも `NoJudge-` の行で入れ、`no_judge.ts` にページを登録し、コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に足す。「`id` を contest_id から導かない」の例外であることも書く。AOJ にあるがコンテスト一覧に無い問題は、問題 ID のまま入れる。`contest_name_and_task_index.ts` は `taskId` が必須になる - - `docs/guides/how-to-add-contest-table-provider.md`(「JAG 模擬地区は AOJ と AtCoder が混在する」の「記号」): 同じ手順を 1 行で足す -- `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`git diff --check` - 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と出典のラベルを目で確かめる -- 共有の型と汎用関数のシグネチャを変えるので、Codex でクロスレビューする +- 共有の型(`UrlGenerator`)と汎用関数(`addContestNameToTaskIndex`)のシグネチャを変えたので、Codex でクロスレビューする ### staging / 本番への投入(メンテナ) @@ -146,7 +75,36 @@ export const addContestNameToTaskIndex = ( - 列は `id`・`contest_type`・`created_at`・`updated_at` も明示する。Supabase の CSV は Prisma の既定値を使わず、`created_at` は DB に既定値があっても空になった例がある(`docs/guides/agent-rules/prisma-db.md`) - `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする - CSV の投入は `invalidateTaskCaches()` を経ないので、画面に出るのはタスクのキャッシュの有効期限(1 時間)が切れた後 -- 行の一覧は段階 5 でここに足す +- 行の一覧は下の表(seed の 26 行と同じ) + +| `task_id` | `contest_id` | `contest_type` | `task_table_index` | `title` | +| ---------------------------- | ------------------ | -------------- | ------------------ | ---------------------------- | +| 1123 | ICPCPrelim2002 | AOJ_ICPC | E | Enclosing Circles | +| NoJudge-JAGRegional2005_c | JAGRegional2005 | AOJ_JAG | C | Geometry?! Why Not?? | +| NoJudge-JAGRegional2005_f | JAGRegional2005 | AOJ_JAG | F | University Rankings | +| NoJudge-JAGRegional2005_h | JAGRegional2005 | AOJ_JAG | H | Tiling Polygons | +| NoJudge-JAGRegional2008_j | JAGRegional2008 | AOJ_JAG | J | Colony Maintenance | +| 2565 | JAGRegional2013 | AOJ_JAG | A | Broken Audio Signal | +| 2566 | JAGRegional2013 | AOJ_JAG | B | Restore Calculation | +| 2567 | JAGRegional2013 | AOJ_JAG | C | SIRO Challenge | +| NoJudge-JAGSummer2006-day1_a | JAGSummer2006-day1 | AOJ_JAG | A | Monkey Business | +| NoJudge-JAGSummer2006-day1_c | JAGSummer2006-day1 | AOJ_JAG | C | Sort that Queue | +| NoJudge-JAGSummer2006-day3_a | JAGSummer2006-day3 | AOJ_JAG | A | Rolling Cubes | +| 3643 | JAGSummer2024-day3 | AOJ_JAG | G | Interactive String Guessing | +| NoJudge-JAGWinter2006-day2_a | JAGWinter2006-day2 | AOJ_JAG | A | Cells | +| NoJudge-JAGWinter2006-day2_b | JAGWinter2006-day2 | AOJ_JAG | B | Japan | +| NoJudge-JAGWinter2006-day2_c | JAGWinter2006-day2 | AOJ_JAG | C | Maximum | +| NoJudge-JAGWinter2006-day2_e | JAGWinter2006-day2 | AOJ_JAG | E | Payment System | +| NoJudge-JAGWinter2006-day2_g | JAGWinter2006-day2 | AOJ_JAG | G | Roof | +| NoJudge-JAGWinter2006-day2_h | JAGWinter2006-day2 | AOJ_JAG | H | Seminar Room | +| NoJudge-JAGWinter2006-day2_i | JAGWinter2006-day2 | AOJ_JAG | I | String Compression | +| NoJudge-JAGWinter2006-day3_b | JAGWinter2006-day3 | AOJ_JAG | B | Widget Factory | +| NoJudge-JAGWinter2006-day3_c | JAGWinter2006-day3 | AOJ_JAG | C | Hills | +| NoJudge-JAGWinter2006-day3_e | JAGWinter2006-day3 | AOJ_JAG | E | Safe Packing | +| NoJudge-JAGWinter2006-day3_f | JAGWinter2006-day3 | AOJ_JAG | F | The "Shortest" Pair of Paths | +| NoJudge-JAGWinter2006-day3_i | JAGWinter2006-day3 | AOJ_JAG | I | Counting Triangles | +| NoJudge-JAGWinter2006-day3_j | JAGWinter2006-day3 | AOJ_JAG | J | Wireing Assistant | +| NoJudge-JAGWinter2007-day3_e | JAGWinter2007-day3 | AOJ_JAG | E | Networking Company | CSV を入れたら、次の SQL で記号を確かめる。`(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える。seed はテストが守るが、CSV は守らない。 diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index 281a0b351..76ff84eda 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -1,13 +1,13 @@ # AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する -**日付**: 2026-09-09 作成、2026-10-24 に PR2 以降を再構成/ **状態**: PR0・PR1・PR1.5(#4219)完了。PR2(#4220)・PR2.1(#4222)は staging に反映、本番は未反映。PR2.5 は実装済み、未マージ +**日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装済み、未マージ。残りは [残りのタスク](#残りのタスク) このファイルは全体像と、PR をまたぐ決定・手順だけを書く。各 PR の作業はそれぞれのファイルにある。 | ファイル | 内容 | | -------------------------------------- | -------------------------------------------------- | -| [plan-pr3.md](./plan-pr3.md) | PR3: 欠落問題の補完 | -| [done.md](./done.md) | PR0〜PR2.5 の決定の要約と残っている作業 | +| [plan-pr3.md](./plan-pr3.md) | PR3: 欠落問題の補完(設計・CSV 投入の手順) | +| [done.md](./done.md) | PR0〜PR3 の決定の要約と、PR 外の残タスク | | [investigation.md](./investigation.md) | 根拠データ。PR2 の照合結果、表示順、欠落の一覧など | ## ゴール @@ -21,33 +21,32 @@ | **G1** 出典欄 | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | | **G2** コンテストテーブル | 問題 ID 3467 → ソート位置から `F` を算出 | `task_table_index` の `F` を**そのまま使う** | -## PR1 完了時点の状態 +## 現在の状態 -| 範囲(コンテスト / 問題) | `task_table_index` | `title` | 管理画面の取り込み | コンテストテーブル | -| ------------------------------------------------------ | ------------------ | --------------------------- | ------------------ | ------------------ | -| ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632) | 記号 | 素 | 記号を付ける | あり | -| JAG 夏 / 冬 / 春合宿(50 / 467。seed と本番) | 問題 ID | `'2389. …'`(問題 ID 付き) | 経路が無い | 無し | -| JAG 模擬地区(16 / 158。本番と staging。seed は 0 件) | 問題 ID | 素 | 問題 ID のまま | 無し | +`task_table_index` は、AOJ ICPC・JAG の全範囲で問題記号になった。表示側の変換は PR1 で削除し、コンテストテーブルのセルは `task_table_index` から `A. タイトル` を組み立てる。出典欄の書式(G1)は AOJ 全体に入っている。 -表示側の変換は PR1 で削除済みで、コンテストテーブルのセルは `task_table_index` から `A. タイトル` を組み立てる。出典欄の書式(G1)は AOJ 全体に入っているので、合宿系・模擬地区の出典欄は今 `JAG 夏合宿 2024 Day3 - 3644(AOJ)` のように問題 ID を出している。 +| 範囲 | `task_table_index` | `title` | 管理画面の取り込み | コンテストテーブル | +| ----------------------------------------------------------- | ------------------ | ------- | ------------------------------------------ | -------------------------------- | +| ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632) | 記号 | 素 | 記号を付ける | あり | +| JAG 夏 / 冬 / 春合宿・模擬地区・in AtCoder(76 コンテスト) | 記号 | 素 | 合宿系は経路が無い。模擬地区は記号を付ける | 「JAG 模擬地区」にコンテストごと | -AOJ の API は 2026-09-07 に別のホストへ移り、旧ホストは 410 を返す。PR1.5(#4219)で追従し、staging では取り込めることを確かめた。本番は `main` への昇格まで取り込めない。 +経緯(詳細は [done.md](./done.md)): -PR2(#4220)で、合宿系と模擬地区も記号になり、合宿系の title は素になった。管理画面の取り込みも、JAG は全 round で記号を付ける(staging に反映。本番は未反映)。 - -PR2.1(#4222)で、AtCoder 上の JAG 4 コンテスト用に `ATCODER_JAG` を新設し、seed に 4 コンテストと 2025 / 2026 の夏合宿を足した(staging に反映し、データも投入した。本番は未反映)。 - -PR2.5 で、グループ「JAG 模擬地区」に 76 コンテストのテーブルを作った(実装済み、未マージ)。 +- PR1.5(#4219): AOJ の API は 2026-09-07 に別のホストへ移り、旧ホストは 410 を返す。追従した +- PR2(#4220): 合宿系と模擬地区を記号にし、JAG は全 round で取り込みでも記号を付けるようにした +- PR2.1(#4222): `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテストと 2025 / 2026 の夏合宿を seed に足した +- PR2.5: グループ「JAG 模擬地区」に 76 コンテストのテーブルを作った +- PR3: AOJ のコンテスト一覧に無い 26 問を足す。AOJ に無い 21 問は `NoJudge-` のダミーにした ## PR の分割と順序 -| | 範囲 | コンテスト | 問題数 | 内容 | -| -------------------- | ------------------------------------------------------- | ---------- | ------------------------- | ---------------------------------------------------------------------------- | -| [PR1.5](./done.md) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。完了(#4219) | -| [PR2](./done.md) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする。staging に反映(#4220) | -| [PR2.1](./done.md) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む)。staging に反映(#4222) | -| [PR2.5](./done.md) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」に、コンテスト別のテーブルを作る。実装済み、未マージ | -| [PR3](./plan-pr3.md) | 欠落問題の補完 | — | — | AOJ のコンテスト一覧に無い問題をどう埋めるか | +| | 範囲 | コンテスト | 問題数 | 内容 | +| -------------------- | ------------------------------------------------------- | ---------- | ------------------------- | ------------------------------------------------------------------------------------ | +| [PR1.5](./done.md) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。完了(#4219) | +| [PR2](./done.md) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする。staging に反映(#4220) | +| [PR2.1](./done.md) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む)。staging に反映(#4222) | +| [PR2.5](./done.md) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」に、コンテスト別のテーブルを作る。実装済み、未マージ | +| [PR3](./plan-pr3.md) | 欠落問題の補完 | 10 | 26 | AOJ のコンテスト一覧に無い問題を足す(AOJ に無い 21 問はダミー)。実装済み、未マージ | PR1 と PR2 で、記号にするのは 1,257 問 / 145 コンテストになる。PR2 で完結し、PR3 に進まなくても機能として成立する。 @@ -57,6 +56,21 @@ PR1 と PR2 で、記号にするのは 1,257 問 / 145 コンテストになる - **PR2.5 のコードは PR2.1 の後に出す**。AtCoder の 4 コンテストの Provider に `ATCODER_JAG` を使い、seed との整合テストも PR2.1 の seed を前提にする(計画では PR2.1 と独立させていたが、実装時に変えた。[理由](./done.md#分類と表示))。本番の PR2.1 のデータ投入は PR2.5 の後でもよいが、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(`TaskTable.svelte` は、データの無い Provider でも見出しと枠を描画する。[残タスク](./done.md#残タスク)) - **PR3 は独立**。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない +### 残りのタスク + +PR3(実装済み): + +1. 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と `(No Judge)` を目で確かめる +2. Codex でクロスレビューする(共有の型と汎用関数のシグネチャを変えたため) +3. PR を開き、staging へマージする +4. コードのデプロイの後に、メンテナが staging へ CSV(26 行)を投入し、[SQL](./plan-pr3.md#staging--本番への投入メンテナ) で記号を確かめる +5. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる + +PR 外の残タスクは [done.md](./done.md#残タスク)(2016 年の模擬国内の取り込み、seed 整合テストの期待値、`TaskTable.svelte` の空の枠、名称の統一 #4018、`title.replace` の置換)。ほかに次の 2 件がある。 + +- AOJ Courses / PCK / 大学系は、数値の index のまま残す(下の[スコープ外](#スコープ外)) +- 本番の title の前後の空白 46 件は、実害が無いので直さない(下記) + ### スコープ外 次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。PR1 から残っているタスクは [done.md](./done.md#残タスク)。 diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 68fa3f2f7..a0b071fce 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -93,6 +93,7 @@ AOJ/JAG 固有の詳細(`titleStyle`、同一年2回開催の `suffix` 分割 - **title**: AtCoder は `A. ` 付き、AOJ は素。セル(`buildTitleWithTaskIndex`)がどちらにも記号を付け直すので、Provider で title を加工しない - **データ**: AOJ の合宿系は管理画面から取り込めないので、seed と Supabase の CSV で記号付きで入れる。AtCoder 側は管理画面から取り込む - **記号**: AOJ は問題 ID の昇順で A, B, C… と振るが、問題が AOJ に無いコンテストではずれる。新しい回はメンテナのスプレッドシートと照合する +- **AOJ に無い問題**: `NoJudge-{contest_id}_{小文字の記号}` の task_id でダミーを足し、`no_judge.ts` にリンク先(jag-icpc.org のページ)を登録し、コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れる。出典欄は `(No Judge)` になる ### 補足: 固定セクションによる同一 ContestType 内の共存 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 8dc228599..001f211ee 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -10419,6 +10419,13 @@ export const tasks = [ name: 'What is the Number in my Mind ?', title: 'What is the Number in my Mind ?', }, + { + id: '1123', + contest_id: 'ICPCPrelim2002', + problem_index: 'E', + name: 'Enclosing Circles', + title: 'Enclosing Circles', + }, { id: '1124', contest_id: 'ICPCPrelim2003', @@ -14731,6 +14738,27 @@ export const tasks = [ name: 'Light The Room', title: 'Light The Room', }, + { + id: 'NoJudge-JAGRegional2005_c', + contest_id: 'JAGRegional2005', + problem_index: 'C', + name: 'Geometry?! Why Not??', + title: 'Geometry?! Why Not??', + }, + { + id: 'NoJudge-JAGRegional2005_f', + contest_id: 'JAGRegional2005', + problem_index: 'F', + name: 'University Rankings', + title: 'University Rankings', + }, + { + id: 'NoJudge-JAGRegional2005_h', + contest_id: 'JAGRegional2005', + problem_index: 'H', + name: 'Tiling Polygons', + title: 'Tiling Polygons', + }, { id: '2030', contest_id: 'JAGRegional2006', @@ -14920,6 +14948,13 @@ export const tasks = [ name: "Cousin's Aunt", title: "Cousin's Aunt", }, + { + id: 'NoJudge-JAGRegional2008_j', + contest_id: 'JAGRegional2008', + problem_index: 'J', + name: 'Colony Maintenance', + title: 'Colony Maintenance', + }, { id: '2175', contest_id: 'JAGRegional2009', @@ -15250,6 +15285,27 @@ export const tasks = [ name: 'Magical Switches', title: 'Magical Switches', }, + { + id: '2565', + contest_id: 'JAGRegional2013', + problem_index: 'A', + name: 'Broken Audio Signal', + title: 'Broken Audio Signal', + }, + { + id: '2566', + contest_id: 'JAGRegional2013', + problem_index: 'B', + name: 'Restore Calculation', + title: 'Restore Calculation', + }, + { + id: '2567', + contest_id: 'JAGRegional2013', + problem_index: 'C', + name: 'SIRO Challenge', + title: 'SIRO Challenge', + }, { id: '2589', contest_id: 'JAGRegional2014', @@ -16102,6 +16158,20 @@ export const tasks = [ name: 'The Closest Circle', title: 'The Closest Circle', }, + { + id: 'NoJudge-JAGSummer2006-day1_a', + contest_id: 'JAGSummer2006-day1', + problem_index: 'A', + name: 'Monkey Business', + title: 'Monkey Business', + }, + { + id: 'NoJudge-JAGSummer2006-day1_c', + contest_id: 'JAGSummer2006-day1', + problem_index: 'C', + name: 'Sort that Queue', + title: 'Sort that Queue', + }, { id: '2058', contest_id: 'JAGSummer2006-day2', @@ -16207,6 +16277,13 @@ export const tasks = [ name: "Philosopher's Stone", title: "Philosopher's Stone", }, + { + id: 'NoJudge-JAGSummer2006-day3_a', + contest_id: 'JAGSummer2006-day3', + problem_index: 'A', + name: 'Rolling Cubes', + title: 'Rolling Cubes', + }, { id: '2079', contest_id: 'JAGSummer2007-day1', @@ -18461,6 +18538,13 @@ export const tasks = [ name: 'Equal or Not Equal', title: 'Equal or Not Equal', }, + { + id: '3643', + contest_id: 'JAGSummer2024-day3', + problem_index: 'G', + name: 'Interactive String Guessing', + title: 'Interactive String Guessing', + }, { id: '3648', contest_id: 'JAGSummer2025-day1', @@ -18979,6 +19063,55 @@ export const tasks = [ name: 'Rakunarok', title: 'Rakunarok', }, + { + id: 'NoJudge-JAGWinter2006-day2_a', + contest_id: 'JAGWinter2006-day2', + problem_index: 'A', + name: 'Cells', + title: 'Cells', + }, + { + id: 'NoJudge-JAGWinter2006-day2_b', + contest_id: 'JAGWinter2006-day2', + problem_index: 'B', + name: 'Japan', + title: 'Japan', + }, + { + id: 'NoJudge-JAGWinter2006-day2_c', + contest_id: 'JAGWinter2006-day2', + problem_index: 'C', + name: 'Maximum', + title: 'Maximum', + }, + { + id: 'NoJudge-JAGWinter2006-day2_e', + contest_id: 'JAGWinter2006-day2', + problem_index: 'E', + name: 'Payment System', + title: 'Payment System', + }, + { + id: 'NoJudge-JAGWinter2006-day2_g', + contest_id: 'JAGWinter2006-day2', + problem_index: 'G', + name: 'Roof', + title: 'Roof', + }, + { + id: 'NoJudge-JAGWinter2006-day2_h', + contest_id: 'JAGWinter2006-day2', + problem_index: 'H', + name: 'Seminar Room', + title: 'Seminar Room', + }, + { + id: 'NoJudge-JAGWinter2006-day2_i', + contest_id: 'JAGWinter2006-day2', + problem_index: 'I', + name: 'String Compression', + title: 'String Compression', + }, { id: '2075', contest_id: 'JAGWinter2006-day3', @@ -19007,6 +19140,48 @@ export const tasks = [ name: 'Exact Arithmetic', title: 'Exact Arithmetic', }, + { + id: 'NoJudge-JAGWinter2006-day3_b', + contest_id: 'JAGWinter2006-day3', + problem_index: 'B', + name: 'Widget Factory', + title: 'Widget Factory', + }, + { + id: 'NoJudge-JAGWinter2006-day3_c', + contest_id: 'JAGWinter2006-day3', + problem_index: 'C', + name: 'Hills', + title: 'Hills', + }, + { + id: 'NoJudge-JAGWinter2006-day3_e', + contest_id: 'JAGWinter2006-day3', + problem_index: 'E', + name: 'Safe Packing', + title: 'Safe Packing', + }, + { + id: 'NoJudge-JAGWinter2006-day3_f', + contest_id: 'JAGWinter2006-day3', + problem_index: 'F', + name: 'The "Shortest" Pair of Paths', + title: 'The "Shortest" Pair of Paths', + }, + { + id: 'NoJudge-JAGWinter2006-day3_i', + contest_id: 'JAGWinter2006-day3', + problem_index: 'I', + name: 'Counting Triangles', + title: 'Counting Triangles', + }, + { + id: 'NoJudge-JAGWinter2006-day3_j', + contest_id: 'JAGWinter2006-day3', + problem_index: 'J', + name: 'Wireing Assistant', + title: 'Wireing Assistant', + }, { id: '2112', contest_id: 'JAGWinter2007-day2', @@ -19140,6 +19315,13 @@ export const tasks = [ name: 'Billion Million Thousand', title: 'Billion Million Thousand', }, + { + id: 'NoJudge-JAGWinter2007-day3_e', + contest_id: 'JAGWinter2007-day3', + problem_index: 'E', + name: 'Networking Company', + title: 'Networking Company', + }, { id: '2457', contest_id: 'JAGWinter2008-day2', diff --git a/src/features/workbooks/components/detail/WorkBookTasksTable.svelte b/src/features/workbooks/components/detail/WorkBookTasksTable.svelte index e79245e40..655555eab 100644 --- a/src/features/workbooks/components/detail/WorkBookTasksTable.svelte +++ b/src/features/workbooks/components/detail/WorkBookTasksTable.svelte @@ -202,6 +202,7 @@ {addContestNameToTaskIndex( task.contestId, getTaskTableIndex(tasksMapByIds, task.taskId), + task.taskId, )} diff --git a/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts b/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts index a98c7eb03..fec5dd654 100644 --- a/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts +++ b/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { ContestType } from '#lib/contests/types/contest.js'; +import { isNoJudgeTask } from '#lib/contests/index.js'; import { classifyContest } from '#lib/contests/utils/classification.js'; import { tasks } from '../../../../prisma/tasks'; @@ -15,9 +16,15 @@ function usesProblemLetters(contestId: string): boolean { } // Problems missing from AOJ shift the basic rule here, so letters follow the maintainer's problem list. +// NoJudge ids cannot be sorted with Number(), so contests with NoJudge tasks must be listed here. const LETTERS_OF_SHIFTED_CONTESTS: Record> = { - 'JAGSummer2006-day1': { '2057': 'B' }, + 'JAGSummer2006-day1': { + 'NoJudge-JAGSummer2006-day1_a': 'A', + '2057': 'B', + 'NoJudge-JAGSummer2006-day1_c': 'C', + }, 'JAGSummer2006-day3': { + 'NoJudge-JAGSummer2006-day3_a': 'A', '2067': 'B', '2068': 'C', '2069': 'D', @@ -25,47 +32,63 @@ const LETTERS_OF_SHIFTED_CONTESTS: Record> = { '2071': 'F', '2072': 'G', }, - 'JAGWinter2006-day2': { '2073': 'D', '2074': 'F' }, - 'JAGWinter2006-day3': { '2075': 'A', '2076': 'D', '2077': 'G', '2078': 'H' }, + 'JAGWinter2006-day2': { + 'NoJudge-JAGWinter2006-day2_a': 'A', + 'NoJudge-JAGWinter2006-day2_b': 'B', + 'NoJudge-JAGWinter2006-day2_c': 'C', + '2073': 'D', + 'NoJudge-JAGWinter2006-day2_e': 'E', + '2074': 'F', + 'NoJudge-JAGWinter2006-day2_g': 'G', + 'NoJudge-JAGWinter2006-day2_h': 'H', + 'NoJudge-JAGWinter2006-day2_i': 'I', + }, + 'JAGWinter2006-day3': { + '2075': 'A', + 'NoJudge-JAGWinter2006-day3_b': 'B', + 'NoJudge-JAGWinter2006-day3_c': 'C', + '2076': 'D', + 'NoJudge-JAGWinter2006-day3_e': 'E', + 'NoJudge-JAGWinter2006-day3_f': 'F', + '2077': 'G', + '2078': 'H', + 'NoJudge-JAGWinter2006-day3_i': 'I', + 'NoJudge-JAGWinter2006-day3_j': 'J', + }, 'JAGWinter2007-day3': { '2122': 'A', '2123': 'B', '2124': 'C', '2125': 'D', + 'NoJudge-JAGWinter2007-day3_e': 'E', '2126': 'F', '2127': 'G', '2128': 'H', '2129': 'I', '2130': 'J', }, - 'JAGSummer2024-day3': { - '3637': 'A', - '3638': 'B', - '3639': 'C', - '3640': 'D', - '3641': 'E', - '3642': 'F', - '3644': 'H', - '3645': 'I', - '3646': 'J', - '3647': 'K', - }, JAGRegional2005: { '2024': 'A', '2025': 'B', + 'NoJudge-JAGRegional2005_c': 'C', '2026': 'D', '2027': 'E', + 'NoJudge-JAGRegional2005_f': 'F', '2028': 'G', + 'NoJudge-JAGRegional2005_h': 'H', '2029': 'I', }, - JAGRegional2013: { - '2568': 'D', - '2569': 'E', - '2570': 'F', - '2571': 'G', - '2572': 'H', - '2573': 'I', - '2574': 'J', + JAGRegional2008: { + '2048': 'A', + '2049': 'B', + '2050': 'C', + '2051': 'D', + '2052': 'E', + '2053': 'F', + '2054': 'G', + '2055': 'H', + '2056': 'I', + 'NoJudge-JAGRegional2008_j': 'J', }, }; @@ -89,14 +112,14 @@ describe('problem_index of AOJ ICPC / JAG tasks in prisma/tasks.ts', () => { const indicesByContest = groupProblemIndicesByContest(); // Guards against the filter silently matching fewer tasks. - test('covers 151 contests and 1329 tasks', () => { + test('covers 151 contests and 1355 tasks', () => { const taskCount = [...indicesByContest.values()].reduce( (sum, indices) => sum + indices.size, 0, ); expect(indicesByContest.size).toBe(151); - expect(taskCount).toBe(1329); + expect(taskCount).toBe(1355); }); test.each( @@ -112,6 +135,16 @@ describe('problem_index of AOJ ICPC / JAG tasks in prisma/tasks.ts', () => { }, ); + test('every contest with NoJudge tasks is in LETTERS_OF_SHIFTED_CONTESTS', () => { + const contestIds = tasks + .filter((task) => isNoJudgeTask(task.id)) + .map((task) => task.contest_id); + + expect(contestIds.filter((contestId) => !(contestId in LETTERS_OF_SHIFTED_CONTESTS))).toEqual( + [], + ); + }); + test('ICPC Prelim 2024 F is problem 1677', () => { expect(indicesByContest.get('ICPCPrelim2024')?.get('1677')).toBe('F'); }); diff --git a/src/lib/components/TaskForm.svelte b/src/lib/components/TaskForm.svelte index 5fee48d96..bfbe15458 100644 --- a/src/lib/components/TaskForm.svelte +++ b/src/lib/components/TaskForm.svelte @@ -48,7 +48,7 @@ 出典 - {addContestNameToTaskIndex(task.contest_id, task.task_table_index)} + {addContestNameToTaskIndex(task.contest_id, task.task_table_index, task.task_id)} diff --git a/src/lib/components/TaskList.svelte b/src/lib/components/TaskList.svelte index 79d5d4ea7..992d5b103 100644 --- a/src/lib/components/TaskList.svelte +++ b/src/lib/components/TaskList.svelte @@ -121,7 +121,11 @@ diff --git a/src/lib/components/TaskListSorted.svelte b/src/lib/components/TaskListSorted.svelte index 1c257b500..e0463bd29 100644 --- a/src/lib/components/TaskListSorted.svelte +++ b/src/lib/components/TaskListSorted.svelte @@ -52,7 +52,11 @@ - {addContestNameToTaskIndex(taskResult.contest_id, taskResult.task_table_index)} + {addContestNameToTaskIndex( + taskResult.contest_id, + taskResult.task_table_index, + taskResult.task_id, + )} {taskResult.updated_at.toLocaleString()} diff --git a/src/lib/contests/fixtures/contest_name_and_task_index.ts b/src/lib/contests/fixtures/contest_name_and_task_index.ts index 1dcbfd0a2..1ba64e3c1 100644 --- a/src/lib/contests/fixtures/contest_name_and_task_index.ts +++ b/src/lib/contests/fixtures/contest_name_and_task_index.ts @@ -9,6 +9,8 @@ import { export type TestCaseForContestNameAndTaskIndex = { contestId: string; taskTableIndex: string; + // Only NoJudge cases set it; the label otherwise depends on contestId alone. + taskId?: string; expected: string; }; @@ -994,3 +996,19 @@ export const aojUniversity = AOJ_UNIVERSITY_TEST_DATA.map( expected, }), ); + +// NoJudge tasks share AOJ contest ids but have no judge, so the label ends with (No Judge). +export const noJudge = [ + createTestCaseForContestNameAndTaskIndex('NoJudge, JAGWinter2006-day2 - A')({ + contestId: 'JAGWinter2006-day2', + taskTableIndex: 'A', + taskId: 'NoJudge-JAGWinter2006-day2_a', + expected: 'JAG 冬合宿 2006 Day2 - A(No Judge)', + }), + createTestCaseForContestNameAndTaskIndex('NoJudge, JAGRegional2005 - C')({ + contestId: 'JAGRegional2005', + taskTableIndex: 'C', + taskId: 'NoJudge-JAGRegional2005_c', + expected: 'JAG 模擬地区 2005 - C(No Judge)', + }), +]; diff --git a/src/lib/contests/index.ts b/src/lib/contests/index.ts index bcedf6b37..2837eb4dc 100644 --- a/src/lib/contests/index.ts +++ b/src/lib/contests/index.ts @@ -12,6 +12,9 @@ export { getJoiContestLabel } from './utils/labels/joi'; export { getAtCoderUniversityContestLabel } from './utils/labels/universities'; export { getAojContestLabel } from './utils/labels/aoj'; +// No Judge tasks (missing from AOJ) +export { isNoJudgeTask, NO_JUDGE_TASK_URLS } from './utils/no_judge'; + // Task index label export { addContestNameToTaskIndex } from './utils/task_index_label'; diff --git a/src/lib/contests/utils/no_judge.test.ts b/src/lib/contests/utils/no_judge.test.ts new file mode 100644 index 000000000..ed62d1d3f --- /dev/null +++ b/src/lib/contests/utils/no_judge.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect } from 'vitest'; + +import { tasks } from '../../../../prisma/tasks'; + +import { isNoJudgeTask, NO_JUDGE_TASK_URLS } from './no_judge'; + +describe('isNoJudgeTask', () => { + test('returns true for task ids starting with NoJudge-', () => { + expect(isNoJudgeTask('NoJudge-JAGWinter2006-day2_a')).toBe(true); + }); + + test('returns false for AOJ and AtCoder task ids', () => { + expect(isNoJudgeTask('1123')).toBe(false); + expect(isNoJudgeTask('abc300_a')).toBe(false); + expect(isNoJudgeTask('nojudge-JAGWinter2006-day2_a')).toBe(false); + }); +}); + +describe('NO_JUDGE_TASK_URLS', () => { + // Expected pages are literals grouped by event, so a task moved to the wrong page fails. + const expectedPages: [string, string[]][] = [ + [ + 'https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF', + [ + 'NoJudge-JAGSummer2006-day1_a', + 'NoJudge-JAGSummer2006-day1_c', + 'NoJudge-JAGSummer2006-day3_a', + ], + ], + [ + 'https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + [ + 'NoJudge-JAGWinter2006-day2_a', + 'NoJudge-JAGWinter2006-day2_b', + 'NoJudge-JAGWinter2006-day2_c', + 'NoJudge-JAGWinter2006-day2_e', + 'NoJudge-JAGWinter2006-day2_g', + 'NoJudge-JAGWinter2006-day2_h', + 'NoJudge-JAGWinter2006-day2_i', + 'NoJudge-JAGWinter2006-day3_b', + 'NoJudge-JAGWinter2006-day3_c', + 'NoJudge-JAGWinter2006-day3_e', + 'NoJudge-JAGWinter2006-day3_f', + 'NoJudge-JAGWinter2006-day3_i', + 'NoJudge-JAGWinter2006-day3_j', + ], + ], + [ + 'https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + ['NoJudge-JAGWinter2007-day3_e'], + ], + [ + 'https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + ['NoJudge-JAGRegional2005_c', 'NoJudge-JAGRegional2005_f', 'NoJudge-JAGRegional2005_h'], + ], + [ + 'https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + ['NoJudge-JAGRegional2008_j'], + ], + ]; + + test.each(expectedPages.flatMap(([url, taskIds]) => taskIds.map((taskId) => [taskId, url])))( + 'maps %s to its jag-icpc.org page url verbatim', + (taskId, url) => { + expect(NO_JUDGE_TASK_URLS.get(taskId)).toBe(url); + }, + ); + + test('has exactly the 21 expected NoJudge tasks', () => { + expect(expectedPages.flatMap(([, taskIds]) => taskIds)).toHaveLength(21); + expect(NO_JUDGE_TASK_URLS.size).toBe(21); + }); + + test('has no entry for AOJ task ids', () => { + expect(NO_JUDGE_TASK_URLS.has('1123')).toBe(false); + }); + + test('every NoJudge task in prisma/tasks.ts has a page url', () => { + const missing = tasks + .filter((task) => isNoJudgeTask(task.id) && !NO_JUDGE_TASK_URLS.has(task.id)) + .map((task) => task.id); + + expect(missing).toEqual([]); + }); + + test('every task id with a page url exists in prisma/tasks.ts', () => { + const seededIds = new Set(tasks.map((task) => task.id)); + const unknown = [...NO_JUDGE_TASK_URLS.keys()].filter((taskId) => !seededIds.has(taskId)); + + expect(unknown).toEqual([]); + }); +}); diff --git a/src/lib/contests/utils/no_judge.ts b/src/lib/contests/utils/no_judge.ts new file mode 100644 index 000000000..fe5df8d6d --- /dev/null +++ b/src/lib/contests/utils/no_judge.ts @@ -0,0 +1,58 @@ +export const NO_JUDGE_TASK_ID_PREFIX = 'NoJudge-'; + +export function isNoJudgeTask(taskId: string): boolean { + return taskId.startsWith(NO_JUDGE_TASK_ID_PREFIX); +} + +type NoJudgePage = { url: string; taskIds: string[] }; + +// Tasks missing from AOJ link to the jag-icpc.org page of their event, where the statements, judge data and reviews live. +// Urls are pasted as opened in a browser, percent-encoded. +const NO_JUDGE_PAGES: NoJudgePage[] = [ + { + url: 'https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF', + taskIds: [ + 'NoJudge-JAGSummer2006-day1_a', + 'NoJudge-JAGSummer2006-day1_c', + 'NoJudge-JAGSummer2006-day3_a', + ], + }, + { + url: 'https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + taskIds: [ + 'NoJudge-JAGWinter2006-day2_a', + 'NoJudge-JAGWinter2006-day2_b', + 'NoJudge-JAGWinter2006-day2_c', + 'NoJudge-JAGWinter2006-day2_e', + 'NoJudge-JAGWinter2006-day2_g', + 'NoJudge-JAGWinter2006-day2_h', + 'NoJudge-JAGWinter2006-day2_i', + 'NoJudge-JAGWinter2006-day3_b', + 'NoJudge-JAGWinter2006-day3_c', + 'NoJudge-JAGWinter2006-day3_e', + 'NoJudge-JAGWinter2006-day3_f', + 'NoJudge-JAGWinter2006-day3_i', + 'NoJudge-JAGWinter2006-day3_j', + ], + }, + { + url: 'https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + taskIds: ['NoJudge-JAGWinter2007-day3_e'], + }, + { + url: 'https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + taskIds: [ + 'NoJudge-JAGRegional2005_c', + 'NoJudge-JAGRegional2005_f', + 'NoJudge-JAGRegional2005_h', + ], + }, + { + url: 'https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + taskIds: ['NoJudge-JAGRegional2008_j'], + }, +]; + +export const NO_JUDGE_TASK_URLS: ReadonlyMap = new Map( + NO_JUDGE_PAGES.flatMap(({ url, taskIds }) => taskIds.map((taskId) => [taskId, url] as const)), +); diff --git a/src/lib/contests/utils/task_index_label.test.ts b/src/lib/contests/utils/task_index_label.test.ts index cf2244eb8..d07777fc7 100644 --- a/src/lib/contests/utils/task_index_label.test.ts +++ b/src/lib/contests/utils/task_index_label.test.ts @@ -5,6 +5,9 @@ import * as TestCasesForContestNameAndTaskIndex from '#lib/contests/fixtures/con import { type TestCaseForContestNameAndTaskIndex } from '#lib/contests/fixtures/contest_name_and_task_index.js'; import { addContestNameToTaskIndex } from '#lib/contests/utils/task_index_label.js'; +// Any id that is not a NoJudge task. +const ORDINARY_TASK_ID = '1'; + describe('add contest name to task index', () => { describe('AtCoder', () => { describe('when contest_id contains abc', () => { @@ -12,8 +15,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -24,8 +29,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -36,8 +43,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -48,8 +57,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -60,8 +71,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -72,8 +85,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -84,8 +99,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -96,8 +113,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -108,8 +127,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -120,8 +141,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -132,8 +155,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -146,8 +171,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -158,8 +185,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -170,8 +199,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -181,7 +212,9 @@ describe('add contest name to task index', () => { test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional-day1'])( 'does not produce AOJ format for %s', (contestId) => { - expect(addContestNameToTaskIndex(contestId, '1')).not.toMatch(/(AOJ)$/); + expect(addContestNameToTaskIndex(contestId, '1', ORDINARY_TASK_ID)).not.toMatch( + /(AOJ)$/, + ); }, ); }); @@ -191,8 +224,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -203,11 +238,27 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); }); }); + + describe('when task_id means a task missing from AOJ', () => { + TestCasesForContestNameAndTaskIndex.noJudge.forEach(({ name, value }) => { + runTests( + `appends (No Judge) for a NoJudge task in a JAG contest: ${name}`, + [value], + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? '')).toEqual( + expected, + ); + }, + ); + }); + }); }); diff --git a/src/lib/contests/utils/task_index_label.ts b/src/lib/contests/utils/task_index_label.ts index 1eb26ddd7..ab326ecef 100644 --- a/src/lib/contests/utils/task_index_label.ts +++ b/src/lib/contests/utils/task_index_label.ts @@ -1,14 +1,20 @@ import { regexForJag, regexForAojUniversity, aojCoursePrefixes } from './prefixes'; import { getContestNameLabel } from './labels/index'; +import { isNoJudgeTask } from './no_judge'; -export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: string): string => { - const contestName = getContestNameLabel(contestId); +export const addContestNameToTaskIndex = ( + contestId: string, + taskTableIndex: string, + taskId: string, +): string => { + const label = `${getContestNameLabel(contestId)} - ${taskTableIndex}`; - if (isAojContest(contestId)) { - return `${contestName} - ${taskTableIndex}(AOJ)`; + // No Judge tasks share AOJ contest ids but have no judge, so check the task id first. + if (isNoJudgeTask(taskId)) { + return `${label}(No Judge)`; } - return `${contestName} - ${taskTableIndex}`; + return isAojContest(contestId) ? `${label}(AOJ)` : label; }; function isAojContest(contestId: string): boolean { diff --git a/src/lib/types/url.ts b/src/lib/types/url.ts index 8c72b21de..c53ed243a 100644 --- a/src/lib/types/url.ts +++ b/src/lib/types/url.ts @@ -5,7 +5,8 @@ * * @method canHandle * @param {string} contestId - The ID of the contest. - * @returns {boolean} - Returns true if the generator can handle the given contest ID, otherwise false. + * @param {string} taskId - The ID of the task within the contest. + * @returns {boolean} - Returns true if the generator can handle the given contest and task IDs, otherwise false. * * @method generateUrl * @param {string} contestId - The ID of the contest. @@ -13,7 +14,7 @@ * @returns {string} - Returns the generated URL for the given contest and task IDs. */ export interface UrlGenerator { - canHandle(contestId: string): boolean; + canHandle(contestId: string, taskId: string): boolean; generateUrl(contestId: string, taskId: string): string; } diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index 298714a79..f5e8b6f31 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -7,6 +7,8 @@ import { type WorkBookTaskBase } from '#features/workbooks/types/workbook.js'; import { ATCODER_BASE_CONTEST_URL, AOJ_TASKS_URL } from '#lib/constants/urls.js'; import { getPrefixForAojCourses, + isNoJudgeTask, + NO_JUDGE_TASK_URLS, getContestPriority, regexForAojUniversity, regexForJag, @@ -21,7 +23,7 @@ import { * @returns The generated URL or empty string if no suitable generator is found */ export const getTaskUrl = (contestId: string, taskId: string): string => { - const generators = urlGenerators.find((generator) => generator.canHandle(contestId)); + const generators = urlGenerators.find((generator) => generator.canHandle(contestId, taskId)); return generators?.generateUrl(contestId, taskId) ?? ''; }; @@ -53,9 +55,25 @@ class AojGenerator implements UrlGenerator { } } +class NoJudgeGenerator implements UrlGenerator { + canHandle(_: string, taskId: string): boolean { + return isNoJudgeTask(taskId); + } + + // Empty on a missing entry so the task never falls through to a dead AOJ link. + generateUrl(_: string, taskId: string): string { + return NO_JUDGE_TASK_URLS.get(taskId) ?? ''; + } +} + // Note: +// NoJudge must precede AOJ: its tasks share JAG contest ids. // Default generator last -const urlGenerators: UrlGenerators = [new AojGenerator(), new AtCoderGenerator()]; +const urlGenerators: UrlGenerators = [ + new NoJudgeGenerator(), + new AojGenerator(), + new AtCoderGenerator(), +]; export const countAcceptedTasks = (taskResults: TaskResults) => { const acceptedResults = taskResults.filter((taskResult: TaskResult) => taskResult.is_ac); diff --git a/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte b/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte index 7e2f4b7ab..00ddd438c 100644 --- a/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte +++ b/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte @@ -103,7 +103,7 @@ - {addContestNameToTaskIndex(task.contest_id, task.task_table_index)} + {addContestNameToTaskIndex(task.contest_id, task.task_table_index, task.task_id)} {@render adminGradeCell(task)} diff --git a/src/routes/workbooks/[slug]/+page.svelte b/src/routes/workbooks/[slug]/+page.svelte index 7e86dc037..dd6b36fd4 100644 --- a/src/routes/workbooks/[slug]/+page.svelte +++ b/src/routes/workbooks/[slug]/+page.svelte @@ -49,7 +49,7 @@ const contestId = getContestIdFrom(taskId); const taskTableIndex = getTaskTableIndex(taskId); - return addContestNameToTaskIndex(contestId, taskTableIndex); + return addContestNameToTaskIndex(contestId, taskTableIndex, taskId); }; const getTaskName = (taskId: string): string => { diff --git a/src/test/lib/utils/task.test.ts b/src/test/lib/utils/task.test.ts index b872b3d7d..e6922e874 100644 --- a/src/test/lib/utils/task.test.ts +++ b/src/test/lib/utils/task.test.ts @@ -108,6 +108,14 @@ describe('Task', () => { }); }); + describe('when a NoJudge task and an AOJ task share a JAG contest id', () => { + TestCasesForTaskUrl.noJudgeJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { + expect(getTaskUrl(contestId, taskId)).toBe(expected); + }); + }); + }); + describe('when contest ids and task ids for AOJ ICPC (Prelim and Regional) are given', () => { TestCasesForTaskUrl.aojIcpc.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { diff --git a/src/test/lib/utils/test_cases/task_url.ts b/src/test/lib/utils/test_cases/task_url.ts index f6d227792..78d0dcd54 100644 --- a/src/test/lib/utils/test_cases/task_url.ts +++ b/src/test/lib/utils/test_cases/task_url.ts @@ -191,6 +191,20 @@ export const aojJag = jagContests.flatMap((jag) => }), ); +// NoJudge tasks share JAG contest ids with AOJ tasks; the task id decides the url. +export const noJudgeJag = [ + createTestCaseForTaskUrl('NoJudge, JAGWinter2006-day2 task A')({ + contestId: 'JAGWinter2006-day2', + taskId: 'NoJudge-JAGWinter2006-day2_a', + expected: 'https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + }), + createTestCaseForTaskUrl('AOJ, JAGWinter2006-day2 task D')({ + contestId: 'JAGWinter2006-day2', + taskId: '2073', + expected: `${AOJ_TASKS_URL}/2073`, + }), +]; + // ICPC contests follow these patterns: // - Contest ID format: ICPC(Prelim|Regional) const icpcContests = [ From c61b40d74cde90ea511d7db63936447bdd1477aa Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 09:14:39 +0000 Subject: [PATCH 09/15] docs: fold done.md and plan-pr3.md into plan.md for task title index Co-Authored-By: Claude Sonnet 5.5 --- .../task-title-index-change-impact/done.md | 229 ----------- .../investigation.md | 20 +- .../plan-pr3.md | 138 ------- .../task-title-index-change-impact/plan.md | 358 ++++++++++++++++-- 4 files changed, 343 insertions(+), 402 deletions(-) delete mode 100644 docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md delete mode 100644 docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md deleted file mode 100644 index 3340f48a6..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md +++ /dev/null @@ -1,229 +0,0 @@ -# 完了分(PR0〜PR3)— 意思決定の要約 - -コードと git の履歴から読み取れない判断と事実だけを、項目ごとにまとめる。変更したファイルや関数は各 PR の差分を見る。全体像は [plan.md](./plan.md)、根拠データは [investigation.md](./investigation.md)。 - -| PR | 内容 | 状態 | -| -------------- | --------------------------------------------------------------------- | -------------------------------- | -| PR0(#4212) | 出典欄の AOJ の書式 | 完了 | -| PR1(#4217) | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を問題記号にする | 完了 | -| PR1.5(#4219) | AOJ API の移転に追従する(#4028) | 完了 | -| PR2(#4220) | JAG 合宿系と JAG 模擬地区(66 / 625)を問題記号にする(#4030) | 完了 | -| PR2.1(#4222) | DB に無い 10 コンテストを入れる(#4221) | 完了(本番のデータも投入済み) | -| PR2.5 | 76 コンテストのテーブルを、グループ「JAG 模擬地区」に作る(#4026) | 完了 | -| PR3 | AOJ のコンテスト一覧に無い 26 問を seed に足す | 実装済み、未マージ。CSV 投入待ち | - -PR0〜PR2.5 の本番・staging での確認(移行、出典欄の表示、データの投入、テーブルの目視確認)はすべて済んだ(2026-10-28 にメンテナが確認)。PR3 は未マージで、staging / 本番への CSV 投入が残っている([plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ))。 - -## 残っている作業 - -### 残タスク - -PR2 以降の計画には含めていない。 - -- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う(Codex / CodeRabbit)。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る。`getContests` 側もそろえる -- seed 整合テストの期待値を `assignProblemLetters` で作っている(PR1 と PR2.1 の Codex)。testing.md の「期待値は literal」に合わない。PR2 では補正したコンテストだけを literal にする(M18)。全件を literal にするかは未判断 -- `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する(PR2.5 の Codex)。`prepareContestTable` が `null` を返して消えるのは行だけ。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので PR2.5 では直さなかった(メンテナの判断) -- テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める。PR2.5 の配列(並びと括弧書き)が移行元になる([名称の違い](./investigation.md#名称の違い)) -- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する(Codex)。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める - -## 何をしたか - -- **PR0**: 出典欄の AOJ 分岐を `${contestName} - ${taskTableIndex}(AOJ)` にし、AOJ のコンテスト名ラベルから全角括弧を外した -- **PR1**: ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 コンテスト / 632 問)の `task_table_index` を問題記号にした。移行 SQL は `prisma/migrations/20261024033928_update_aoj_task_table_index/`。管理画面の取り込みでも、この範囲は基本ルールで記号を付ける。表示側の変換(`buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` / `formatAojTitle` / `getTaskLabels`)は削除した -- **PR1.5**: `AOJ_API_BASE_URL` を `https://judgeapi.u-aizu.ac.jp/` から `https://onlinejudge.u-aizu.ac.jp/api/` に変えた。旧ホストは 410 を返し、管理画面からの AOJ の取り込み(Courses / PCK / JAG / ICPC)がすべて失敗していた - - `clients.test.ts` のクライアントを本番の定数から作り、nock で受ける URL(`/api/…`)を期待値として直書きした。`nock.disableNetConnect()` も足した - - 開発コンテナの外向き通信の許可リスト(`.devcontainer/init-firewall.sh`)の `judgeapi.u-aizu.ac.jp` を `onlinejudge.u-aizu.ac.jp` に置き換えた -- **PR2**: JAG 合宿系(夏 / 冬 / 春、50 コンテスト / 467 問)と JAG 模擬地区(16 / 158)の `task_table_index` を、問題 ID から問題記号にした。合宿系の title から、前に付いていた `<問題 ID>. ` を外した。移行 SQL は `prisma/migrations/20261025000719_update_aoj_jag_task_table_index/` - - seed に、本番だけにあった模擬地区 158 問を足した(grade は本番のまま。3355 の title は trim) - - 管理画面の取り込みで、JAG は全 round(模擬国内 / 模擬地区)で記号を付けるようにした -- **PR2.1**: ContestType `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテスト(`jag2018summer-day2` / `jag2017summer-day1` / `jag2015summer-day3` / `jag2013summer-warmingup`)を完全一致で分類するようにした。マイグレーションは `prisma/migrations/20261025030756_add_atcoder_jag_to_contest_type/` - - seed に、この 4 コンテスト(42 問)と、2025 / 2026 の JAG 夏合宿(`JAGSummer2025-day1`〜`JAGSummer2026-day3`、AOJ 3648–3719、6 コンテスト / 72 問、記号 A–L・title は素)を足した。値はメンテナから受け取った(AtCoder 側は AtCoder Problems の値) -- **PR2.5**: グループ「JAG 模擬地区」を「JAG 模擬国内」の後に足し、合宿系 50・模擬地区 16・PR2.1 の 10 の計 76 コンテストを、コンテストごとのテーブルにした。並びと括弧書きは `aoj_jag_regional_entries.ts` の配列で持つ。中身は [investigation.md の表示順](./investigation.md#表示順76-コンテスト) と同じ -- **PR3**: AOJ のコンテスト一覧に無い公式の 26 問を seed に足した。AOJ にある 5 問(1123 / 3643 / 2565〜2567)は AOJ の ID のまま、AOJ に無い 21 問は `NoJudge-{contest_id}_{小文字の記号}` のダミーにした - - `src/lib/contests/utils/no_judge.ts`: `isNoJudgeTask()` と、task_id から jag-icpc.org の大会区分ページ(計 5 ページ)を引く `NO_JUDGE_TASK_URLS` - - `UrlGenerator.canHandle` に `taskId` を足し、`NoJudgeGenerator` を `AojGenerator` の前に置いた。map に漏れがあれば空文字を返し、AOJ には回さない - - `addContestNameToTaskIndex` に必須の `taskId` を足し、ダミーの出典欄は `(No Judge)` にした。呼び出し元 6 ファイルは `pnpm check` で漏れが無いことを確かめた - - `seed_problem_letters.test.ts`: 件数を 1,355 問にし、ダミーのある 7 コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れた(`JAGRegional2008` は新規)。連番に戻った `JAGRegional2013` と `JAGSummer2024-day3` は外した。ダミーのコンテストの入れ忘れを検出するテストも足した - - `add-tasks` のスキルと `how-to-add-contest-table-provider.md` に、ダミーで入れる手順を足した - -## 進め方 - -| 決定 | PR | 理由 | -| ---------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR0 → PR1 → 合宿系の順にする | 全体 | PR0 は DB に依存しない。PR1 は出典欄の成果がすぐ見え、欠落が少ないので対応表の形式を楽なケースで決められる。逆順はレビュー負荷が低いが、出典欄の反映が遅れ、対応表の形式を最も判断の重い合宿系で作ることになる | - -## 移行 SQL - -| 決定 | PR | 理由 | -| ----------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 対応表を焼き込まず、基本ルールを SQL で表す | PR1 | 本番のタスクは管理画面の取り込みで入っており、seed と一致する保証が無い。`VALUES` で書くと本番にしか無い行が残り、`UPDATE` は該当行が無くてもエラーにならないので、取りこぼしが静かに起きる | -| 基本ルールで振り、8 コンテストはコンテストの全行を上書きする(45 行) | PR2 | 8 コンテストは先頭や途中の問題が AOJ に無く、基本ルールでは記号がずれる。正しい記号は、メンテナのスプレッドシートの位置(欠けた問題を飛ばした位置)で決まる。全行を上書きするのは、1 つのコンテストに 2 つの規則を混ぜないため | -| 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る | PR1 / PR2 | 合宿系も模擬国内と同じ `AOJ_JAG` に分類される。PR1 で `contest_type` だけで絞ると合宿系 467 件まで更新し、title のプレフィックスが表に出る。PR2 は `^JAG(Summer\|Winter\|Spring\|Regional)` で絞り、PR1 で記号にした模擬国内を外す | -| 各ステップの更新件数を DO ブロックで検査し、想定外なら RAISE する | PR2 | UPDATE は対象が 0 件でもエラーにならない。想定値は、空の DB(`prisma migrate reset`)の 0、模擬地区の無い開発 DB の 467 / 32 / 467、staging・本番の 625 / 45 / 467 | -| 件数は固定値のまま。模擬地区が一部だけの DB では RAISE し、エラー文で「全部取り込むか、全部消す」と案内する | PR2 | 本番で範囲の誤りを止める力を保つため。模擬地区だけ実際の件数に合わせる案は、範囲の誤りを見逃しやすくなるので採らなかった | -| 順位はコンテストの全行で付け、更新は問題 ID のままの行だけにする | PR2 | 後から記号付きで入る行(PR2.1)が先にあっても触らないため。順位を付ける側でも絞ると、記号の行と問題 ID の行が混ざったコンテストで A から振り直してしまう(Codex の指摘) | -| title から外すのは「自分の `task_id` + `. `」で始まるものだけ | PR2 | `^[0-9]+\. ` で外すと、番号で始まる本来の title まで削りうる | -| `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない | PR1 | 本番でも index は既に正しい。AOJ に 1 問しか無いので、大学系に範囲を広げるときは基本ルール(`A` になる)の対象から外す | - -分かったこと: - -- **SQL**(PR1): `row_number()` は `bigint` を返し、`chr(bigint)` は存在しないので `::int` が要る。`EXPLAIN` は `task` の Seq Scan 2 回 + Hash Join(`contest_type` に索引が無いため想定どおり) -- **トランザクション**(PR1): マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで、1–2 文目も残らないことをローカルで確認) -- **ローカルでの移行**(PR2): 管理画面で模擬地区を取り込み、本番と同じ状態にしてから確かめた。`SELECT` のプレビューは 625 行すべてで照合の結果と一致し、適用後もプレビューと 625 / 625 で一致した。範囲外の AOJ(PCK、大学系、PR1 の範囲)は変わらない。`EXPLAIN` は CTE 側の Seq Scan 1 回と、`task_task_id_key` の Index Scan を使う Nested Loop -- **Codex の指摘の再現**(PR2): 記号の行と問題 ID の行が混ざったコンテストを読み取り専用で再現すると、直す前の SQL は A から振り直し、直した後は B, C, D と正しく振った。ローカルは移行前のスナップショットに戻し、直した移行を適用し直した(625 / 625)。「`task_id` だけで結合している」という指摘は、`task_id` が schema で `@unique` なので該当しない -- **staging**(PR2): 当初は模擬地区が 2 コンテスト / 16 問しか無く、件数チェックで RAISE するところだった。PR1.5 の後に staging で取り込み、PR を開く前に本番と同じ状態(模擬地区 16 / 158、合宿系 50 / 467、index はすべて問題 ID、title の問題 ID は合宿系だけ)であることを確かめた。#4220 で反映した(2026-10-25)。移行の後に Vercel のデプロイが走るので、件数チェックも通っている - -## 照合(記号の対応表) - -- **PR1**: メンテナのスプレッドシートを 2026-10-21 のスクリーンショットから書き写し、632 問すべてが基本ルールの記号の位置で一致した(補正なし)。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に 1123 として存在するが、コンテスト一覧に無いので取り込まれていない(末尾なので記号はずれない。PR3 の対象) -- **PR2**: スプレッドシートの書き写し(66 コンテスト)と 625 問の title が、記号の位置ですべて一致した。補正は 8 コンテスト / 45 行(事前の目視どおり) -- **PR2.1**: 114 問すべてが、スプレッドシートの問題名と記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で欠落が無く、基本ルールと一致する -- **照合スクリプト**: PR1 は `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`、PR2 は `git show c605d3bd:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_jag_problem_letters.ts`。`prisma/tasks.ts` を import し、件数が既知の値と違えば止まる。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。スプレッドシート自体の誤りは検出できない -- **スクリプトの検出力**: わざと誤りを入れたコピーを、PR1 / PR2 とも 5 種類すべて検出した。PR1 は順番の入れ替え、途中の title の誤り、途中の欠番、問題数の不足、切れた title の前半の誤り。PR2 は欠けた記号を外す、順番の入れ替え、途中の title の誤り、欠けた数の不足、切れた title の前半の誤り - -## 取り込み(管理画面・AOJ API) - -| 決定 | PR | 理由 | -| ---------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 取り込みでは、対応表ではなく基本ルールで記号を付ける(M5) | PR1 | 対応表を AOJ client に置く案は、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる | -| 取り込みでは補正しない(M14) | PR2 | 8 コンテストは本番・staging・seed のすべてにあり、取り込みは既存の問題を skip する。Codex も「取り込みで補正すべき」と指摘したが、同じ理由で採らなかった | -| 合宿系は管理画面の取り込みに足さない | PR2.1 | 合宿系も AOJ API の JAG の filter(summer / winter / spring)にあるが、既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加には重い。応答の形は意図的に確かめていない(メンテナの判断) | -| AOJ API はベース URL だけを変える | PR1.5 | AOJ Developers の告知(2026-09-07 に Main API が移転)で変わったのはホストだけ。メンテナが新しい API をブラウザで開き、取り込みが使う 8 つのエンドポイント(`courses`、`problems?size=10000`、`challenges` の 6 種別)のパスと応答の形が fixture と同じことを確かめた | - -分かったこと: - -- **2 日開催の JAG 模擬国内**(PR1): AOJ API には `JAGPrelim20162`(2738–2751)のように 2 日を 1 コンテストにしたものがある。取り込みは `contest_id` 単位で振るので A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存の問題を skip するので影響しない -- **応答の違い**(PR1.5): 値の違いは最上位の `largeCl.id` が小文字になったこと(`"JAG"` → `"jag"` など)だけ。コードはここを読まない。fixture に無い回(ICPC 国内予選 2026、JAG 模擬国内 2026、PCK 予選 2026 など)は、fixture の記録(2026-05)の後に AOJ に追加されたもの -- **ベース URL の落とし穴**(PR1.5): `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった -- **問題ページへのリンク**(PR1.5): `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける(メンテナが確認) -- **staging(Vercel)での取り込み**(PR1.5): 2026-10-24 にメンテナが JAG REGIONAL を取り込み、16 / 158 になった(PR2 の前提)。こちらの取得ツールが新しいホストに `ECONNREFUSED` で拒まれたことから送信元の制限を心配していたが、Vercel からは問題なく取得できた - -## 欠落問題の補完(PR3) - -| 決定 | 理由 | -| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| AOJ に無い問題は、`NoJudge-` 接頭辞のダミー Task にする | 分岐の理由(採点が無い)をそのまま表し、数字(AOJ の領域)とも AtCoder の形式とも衝突しない。`Missing-` は AOJ にある 5 問にも当てはまり、`jag-icpc_` は置き場所を表す | -| リンク先は jag-icpc.org の大会区分のページ。URL は組み立てず、開けたものをそのまま貼る | 問題文・ジャッジデータ・講評のどれにも下りられる。PDF への直リンクは講評に辿れず、規則の推測が外れると黙って間違ったリンクになる | -| リンクは URL ジェネレータの先頭に足し、判定は `isNoJudgeTask()` に一本化する | `getTaskUrl()` に特別扱いを入れず、出典のラベルと基準をそろえる。`isNoJudgeTask()` を `src/lib/utils/` に置くと `lib/contests` と循環 import になるので、`lib/contests` に置く | -| 出典欄は `(No Judge)`。アイコンもツールチップも付けない | 表のセルに幅が無く、スマホにはホバーが無い。引っかかっても、新しいタブで開けば提出フォームの無い Wiki だと分かり、損は小さい | -| AOJ に後から追加されたら、管理画面から取り込まず、DB の `task_id` を UPDATE する | 取り込みは欠けたコンテストに後から足された問題へずれた記号を付け、重なると表から黙って消える。外部キー 6 つは `ON UPDATE CASCADE`([手順](./investigation.md#aoj-に後から追加されたとき)) | -| 3643 は公開前かもしれないが入れる | 2026-10-10 に AOJ に追加された。合宿系は管理画面の取り込みの対象外なので、待っても取り込めない | - -分かったこと: - -- **実装中の変更**: `task_index_label.test.ts` の既存ケースが約 80 件あるので、fixture の `taskId` は省略できる項目にし、テスト側で通常の ID を補った。必須にしたのは関数の引数だけ -- **確認の手段**: seed との突き合わせのテスト 2 件(ダミーは全件 map にある・map のキーは全件 seed にある)が、map の漏れと打ち間違いを守る。CSV の記号は投入後の SQL で確かめる - -## 分類と表示 - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| AOJ ラベルから全角括弧を外す(M11) | PR0 | 出典欄の目標書式ではコンテスト名を括弧で囲まない。ラベルは検索・投票・タグ・取り込み画面・提出状況の更新モーダルでも使われており、その表示も変わるが許容した | -| 表示側の変換と `getTaskLabels` を削除する(M7 / M10) | PR1 | DB が記号を持てば変換は恒等になる。インターフェイスの `getTaskLabels` だけを残す案は、上書きする Provider が 0 になり使われないコードが残るので採らなかった | -| ヘッダーの並びは `localeCompare`(M4) | PR1 | 記号に `Number()` を当てると NaN になり、ソートが効かなくなる | -| `buildTitleWithTaskIndex` は「外してから付ける」 | PR1 | AtCoder 側の title は `A. …` を含むので、付けるだけだと二重になる。title にプレフィックスの無い AtCoder の問題は `abs` の practice_1 だけで(本番も同じ)、ABS は index を表示しない | -| `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す(レビュー) | PR1 | 記号は title の本文にも現れうる。以前は index が空のとき `Dr. Podboq` → `DrPodboq` のように本文の `. ` まで消していた | -| `ATCODER_JAG` を新設し、4 つを contest_id の完全一致で分類する(M17) | PR2.1 | `jag` の前方一致だと、AtCoder Problems の `JAG2013Spring` / `JAG2014Spring`(AOJ の `JAGSpring2013` / `2014` と同じコンテスト)まで入り、問題が二重になる。`OTHERS` に入れないのはメンテナの判断。`AOJ_JAG` だと出典欄の `(AOJ)` や `getTaskUrl` が AOJ を前提に動く | -| ラベル(出典欄)に(有志)を付けない | PR2.1 | PR2.5 のテーブル名でだけ、2018 Day2 / 2017 Day1 に付ける(M16) | -| 合宿系・模擬地区・AtCoder 上の JAG を 1 グループにし、テーブルはコンテストごとにする(M15) | PR2.5 | 同じ年の中の前後関係(模擬地区 2014 は夏合宿 2014 の Day3 として開催)を表せるのは 1 グループだけ。既存の「JAG 模擬国内」とは統合しない(年のループで完成しており、統合するとそれも配列に置き換えることになる) | -| 並びは明示的な配列で持つ | PR2.5 | スプレッドシートの行順(新しい順)は contest_id から導けない(冬合宿 2010 は夏合宿 2011 の後、など)。今後の合宿は seed と配列の両方に足す(配列の書き漏れは seed との整合テストで落ちる) | -| テーブル名は `getContestNameLabel` の名称に括弧書きを付け、出典欄は変えない(M16) | PR2.5 | スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にすると、出典欄と食い違う。出典欄もそろえると、ラベルを使う検索・投票・タグなどと fixture に波及するので #4018 に回した。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない | -| Provider の `contestType` は、AtCoder 上の 4 コンテストだけ `ATCODER_JAG`、ほかは `AOJ_JAG` | PR2.5 | 計画では、PR2.1 と順序を問わないように全件 `AOJ_JAG` にしていた。PR2.1 が先に staging に入ったので、実際の分類に合わせた(メンテナの判断)。`contestType` が効くのは provider key と `getTaskTableHeaderName`(違いは ABC の H/Ex だけ)なので、表示は変わらない | -| グループのキーは `aojJagRegional` | PR2.5 | キーは localStorage(`contest_table_providers`)に保存され、後から変えると利用者の選択が消える。`aojJagPrelim` と対にし、配列のファイル・型・定数(`aoj_jag_regional_entries.ts`)とクラス(同じファイルの `JagPrelimProvider` と対の `JagRegionalProvider`)もこの名前にそろえた(メンテナの判断) | - -分かったこと: - -- **表示の回帰**(PR1): 旧コードで適用前のデータを、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 -- **Codex のレビュー**(PR2.5): 指摘は 2 件で、上の空の枠(残タスクへ)と、plan.md の「PR2.1 は PR2.5 と前後してよい」が古いこと(直した)。Codex が確かめられなかった 2 点はこちらで確かめた。AtCoder の 4 コンテストのセルは AtCoder に飛ぶ(`getTaskUrl` が AOJ に振るのは大文字の `JAG…` だけ)。title の記号は二重にならない(`buildTitleWithTaskIndex` が先頭の `A. ` を外してから付ける) -- **DB の `contest_type`**(PR2.1): 書き込むだけで、表示などで読む箇所は無い(分類は毎回 `contest_id` から引く)。enum の値は `ADD VALUE` で DB 上の末尾に足され、schema.prisma の並びと違うが、並べ替えにも使っていない - -## seed とデータの投入 - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 前後に空白のある title(本番 46 件、3355 の末尾のタブを含む)は直さない | PR2 | 表示では空白が潰れ、完全一致で比べるコードも無い。取り込みが trim するので再発もしない([plan.md](./plan.md#スコープ外)) | -| staging / 本番の 2025 / 2026 夏合宿は、PR2 の移行の後に Supabase の CSV で記号付きで入れる | PR2.1 | 移行の前に問題 ID のまま入れると、PR2 の件数の想定値が変わる。記号付きなら移行と独立する | -| CSV の `id` は既存の合宿系と同じ `sha256(contest_id + title)` | PR2.1 | 取り込みの `sha256(contest_id + task_id)` とは違うが、`Task.id` を再計算して引く箇所が無い([根拠](./investigation.md#id-の採番規則が経路ごとに違う)) | -| seed の AOJ 72 行は古い順・問題 ID の昇順に置く | PR2.1 | add-tasks の規則(新しい順・降順)は AtCoder 側の慣習で、既存の AOJ のブロックはすべて昇順。Codex の指摘は採らなかった | - -分かったこと: - -- **seed の模擬地区**(PR2): 本番から取り出した 158 問は、AOJ の fixture(2026-05 記録)と問題 ID・コンテスト・名前がすべて一致した -- **ローカルの取り込み**(PR2.1): 2026-10-25 にメンテナが AtCoder の 4 コンテストを取り込み、42 行が task_id・contest_id・記号・title まで seed と一致した -- **staging への投入**(PR2.1): 2026-10-25 にメンテナが、管理画面から AtCoder の 4 コンテストを取り込み、2025 / 2026 夏合宿の CSV を投入した。10 コンテストの行は `ATCODER_JAG` 42 行・`AOJ_JAG` 72 行で、想定どおり - -### PR2.1 をロールバックするとき - -取り込みの後に PR2.1 より前の版へ戻すと、旧版の Prisma Client は `ATCODER_JAG` を読めない。取り込みは既存の task_id を skip するので、戻した値はロールフォワードしても残る。 - -1. ロールバックの前に、該当行を `OTHERS` に変え、返った `expected_count` を環境名とともに控える(全問を取り込んでいれば 42)。復旧が終わるまで、4 コンテストの追加・削除・分類の変更はしない - -```sql -WITH changed AS ( - UPDATE "task" - SET "contest_type" = 'OTHERS' - WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') - AND "contest_type" = 'ATCODER_JAG' - RETURNING "id" -) -SELECT count(*) AS expected_count FROM changed; -``` - -2. ロールフォワードでは、PR2.1 以降をデプロイしてから、`expected_count` を控えた件数に置き換えて実行する。件数が違えば `RAISE` で UPDATE ごと取り消されるので、期待値を書き換えて通さず、記録と対象行を調べる。最後の SELECT が `ATCODER_JAG` の 1 行(件数は控えた値)だけを返せば復旧完了。控えた件数が 0 なら SELECT も 0 行になる。`OTHERS` など別の分類が残れば、復旧は終わっていない - -```sql -DO $$ -DECLARE - expected_count integer := 42; -- Replace with the recorded rollback count. - restored_count integer; -BEGIN - UPDATE "task" - SET "contest_type" = 'ATCODER_JAG' - WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') - AND "contest_type" = 'OTHERS'; - - GET DIAGNOSTICS restored_count = ROW_COUNT; - IF restored_count <> expected_count THEN - RAISE EXCEPTION 'restored % rows, expected %', restored_count, expected_count; - END IF; - RAISE NOTICE 'restored % rows, expected %', restored_count, expected_count; -END $$; - --- Expected: ATCODER_JAG only, with the recorded rollback count. -SELECT "contest_type", count(*) FROM "task" -WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') -GROUP BY "contest_type"; -``` - -## テスト - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| seed の検査は、補正した 8 コンテストを literal で、title を「問題名(`name`)と同じ」で見る | PR2 | 補正の正しさは基本ルールの関数では検査できない。title は「問題 ID が前に付いていない」だけだと、空や別の問題の title にすり替わっても通る(Codex の指摘) | -| AOJ の fixture は取り直さない | PR1.5 | 応答の形が同じため。取り直すと統計値(`solvedUser` など)や新しい回の追加で差分が出て、fixture に依存するテストの期待値まで変わる | -| AOJ のテストは nock のまま、クライアントを本番の定数から作る | PR1.5 | CI で実 API を叩かないために nock でモックするのはメンテナの意図どおりで、そのまま残す。問題は、テストが自前の旧ホストからクライアントを作っていて、定数を間違えても通ることだった | -| `nock.disableNetConnect()` を足す | PR1.5 | モックを書き忘れたリクエストが、黙って実 API に飛ぶのを防ぐ | -| 配列の contest_id を、seed の JAG 合宿系・模擬地区・AtCoder 上の JAG と突き合わせる | PR2.5 | 計画のテスト(件数・先頭と末尾・2014 の並び・括弧書き)だけでは、手書きの 76 件の id の誤り(そのテーブルが黙って表示されなくなる)を検出できない(メンテナの判断で追加) | - -分かったこと(`verify-test-strength` による変異テスト): - -- **PR1**: 15 個の変異をすべて検出した。seed 整合テストの `ICPC Prelim 2024 F is problem 1677` は単独で検出する変異が無いが、唯一の literal な期待値(対応表とデータが一緒にずれたときの歯止め)なので残した -- **PR1.5**: `AOJ_API_BASE_URL` の 4 つの変異(末尾の `/` を外す、旧ホストに戻す、`/api/v1/` にする、`/api` を外す)は、どれも 26 件すべてのテストで検出された -- **PR2**: 6 つの変異をすべて検出した(JAG の記号を PRELIM だけ / ICPC だけにする、補正した記号を基本ルールに戻す、基本ルールのコンテストで A・B を入れ替える、title に問題 ID を付け直す、模擬地区の記号を問題 ID に戻す)。title のテストは、空の title・別の問題の title・問題 ID の付け直しも検出する -- **PR2.1**: 9 つの変異をすべて検出した(seed の記号の入れ替え・行の欠落・title の不一致、map の欠落、優先度の入れ替え、`jag` / `jag2` の前方一致、4 つのキーでの前方一致、ラベルの定数化)。4 つのキーでの前方一致は当初見逃しており、Codex の指摘でテストを足した -- **PR2.5**: 11 個の変異をすべて検出した(AtCoder の 4 件を `AOJ_JAG` にする、分類の判定を反転する、括弧を半角にする、括弧書きを常に付ける、`abbreviationName` を定数にする、provider key から contest_id を外す、2014 の模擬地区を Day4 の後に動かす、配列の行の欠落・先頭の行の欠落・id の誤り・id の重複)。テストで並びを見ているのは先頭・末尾・2014 の 3 件だけなので、配列の並びと括弧書きは investigation.md の表と機械的に突き合わせ、76 件すべて一致した - -## 開発環境(dev container) - -| 決定 | PR | 理由 | -| ------------------------------------------------------ | ----- | --------------------------------------------------------------------------------------------------- | -| 許可リストの旧ホストは残さず、新しいホストに置き換える | PR1.5 | 旧ホストは 410 を返すだけで使い道が無い。許可先はデータを持ち出せる経路でもあるので、数を増やさない | - -分かったこと(ローカルの管理画面の取り込みが 500 になった原因。ブラウザにはどちらも「Failed to load resource: 500」とだけ出る): - -- **許可リスト**(PR1.5): 開発サーバーはコンテナの中で動き、外向き通信は許可リスト方式で、新しいホスト(163.143.87.177)が入っていなかった(旧ホストは 163.143.87.219)。サーバーの `fetch` が接続で拒否され、管理画面のアクションが `fail(500)` を返していた。ブラウザはホストの Mac から直接開くので見えていた。`init-firewall.sh` は Dockerfile の `COPY` でイメージに入り、起動時に 1 回だけ DNS で IP を引くので、編集を反映するにはコンテナの再起動ではなく再ビルドが要る。再ビルドの後、2026-10-24 にメンテナがローカルで AOJ から取得できることを確かめた -- **古い Prisma Client**(PR2.1): enum を足す前から動いている開発サーバーは、古い Prisma Client を持ったままで、`ATCODER_JAG` の登録が 500 になる。Vite はソースの変更を反映するが `node_modules` は読み込み直さないので、一覧には新しいコンテストが出る。開発サーバーの再起動で直る - -分かったこと(ローカルでテーブルが出ない): - -- **seed の流し直し**(PR2.5): PR2.1 より前に seed したローカル DB には 2025 / 2026 の夏合宿が無く、「JAG 模擬地区」にテーブルが出なかった。seed は既存の行を skip して足りない行だけ入れるので、`pnpm db:seed` を流し直せば入る diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 3ccca8a1d..2af94ea18 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -12,13 +12,13 @@ | [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | | [7](#7-pr3-欠けている問題) | 欠けている問題の調査、ダミーの設計、影響範囲 | PR3 | -PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [done.md](./done.md)。 +PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [plan.md](./plan.md#完了分の詳細pr0pr3)。 --- # 1. PR2 の照合(2026-10-24) -メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。目視の結果で、PR2 の照合スクリプト(書き写したスプレッドシートとの機械的な照合)でも同じ結果になった([done.md](./done.md#照合記号の対応表))。 +メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。目視の結果で、PR2 の照合スクリプト(書き写したスプレッドシートとの機械的な照合)でも同じ結果になった([plan.md](./plan.md#照合記号の対応表))。 スプレッドシートで折りたたまれた行(40–50 ほか 19 か所)は JAG 模擬国内だけで、合宿系・模擬地区は含まない(メンテナに確認済み)。 @@ -359,7 +359,7 @@ PR2 後: index='A', title='2389. Asynchronous Exceptions' # 4. 検討の経緯(採らなかった案) -**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。PR3 の採らなかった案は [plan-pr3.md](./plan-pr3.md#採らなかった案) にまとめた。 +**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。PR3 の採らなかった案は [plan.md](./plan.md#採らなかった案) にまとめた。 ## `title` に記号を入れる案を採らない理由 @@ -453,7 +453,7 @@ Task を削除するコードが存在しないため、関連テーブルが孤 # 7. PR3: 欠けている問題 -[plan-pr3.md](./plan-pr3.md) の根拠。確認はすべて 2026-10-28 時点(メンテナのスクリーンショットと AOJ の API、リポジトリのコード)。採らなかった案は [plan-pr3.md](./plan-pr3.md#採らなかった案) に 1 か所にまとめた。 +[plan.md の PR3 の節](./plan.md#pr3-欠落問題の補完) の根拠。確認はすべて 2026-10-28 時点(メンテナのスクリーンショットと AOJ の API、リポジトリのコード)。採らなかった案は [plan.md](./plan.md#採らなかった案) に 1 か所にまとめた。 ## 調べ方と結果 @@ -515,14 +515,14 @@ PR3 で記号(`task_table_index`)がずれる心配は、事実上ない。 - **今ある記号は変わらない**。各行に保存された値で、PR2 で補正済み(本番・staging とも反映済み)。PR3 は行を足すだけで、書き換えない。画面も保存された値を使い、問題 ID から計算し直さない。PR1 / PR2 のマイグレーションが再び流れることもない([影響範囲](#影響範囲)) - **足す 26 行の記号は、2 つ以上の根拠がそろう**。ダミーの 21 問は、スプレッドシートの位置と jag-icpc.org(年度ページの問題一覧で 5 コンテスト、問題文の PDF の表紙で 2 コンテスト)。AOJ にある 5 問は、スプレッドシートの位置と AOJ の ID の並び(1119〜1123 の E、3642 と 3644 の間の G、2565〜2574 の A〜C) - **空いている位置にちょうど収まる**(下表)。今ある記号も、年度ページの問題一覧と一致した(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005) -- **seed はテストが固定する**。`seed_problem_letters.test.ts` が記号を 1 問ずつ確かめる([plan-pr3.md の段階 4](./plan-pr3.md#段階-4-seed-のデータ26-行)) +- **seed はテストが固定する**。`seed_problem_letters.test.ts` が記号を 1 問ずつ確かめる([plan.md の PR3 の節](./plan.md#pr3-欠落問題の補完)) 残るのは人の作業の誤りで、どちらも確かめる手順がある: -| リスク | 起きる条件 | 守り | -| -------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 本番の CSV の打ち間違い | メンテナが作る CSV の記号を間違える | 投入後の SQL で、記号の重なりと 10 コンテストの記号を確かめる([plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ))。見つかっても UPDATE 1 本で直る | -| 管理画面の取り込みで、ずれた記号が付く | 将来 AOJ が欠けた問題を足し、それを管理画面から取り込む(PR3 の前からある危険) | 取り込まず、ダミーの `task_id` を UPDATE する([手順](#aoj-に後から追加されたとき)) | +| リスク | 起きる条件 | 守り | +| -------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| 本番の CSV の打ち間違い | メンテナが作る CSV の記号を間違える | 投入後の SQL で、記号の重なりと 10 コンテストの記号を確かめる([plan.md](./plan.md#staging--本番への投入メンテナ))。見つかっても UPDATE 1 本で直る | +| 管理画面の取り込みで、ずれた記号が付く | 将来 AOJ が欠けた問題を足し、それを管理画面から取り込む(PR3 の前からある危険) | 取り込まず、ダミーの `task_id` を UPDATE する([手順](#aoj-に後から追加されたとき)) | ダミーで足す記号: @@ -628,7 +628,7 @@ const urlGenerators: UrlGenerators = [ レコードの入れ方: - seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から決める(ダミーの 7 コンテストは `AOJ_JAG`) -- staging / 本番: メンテナが Supabase の CSV で入れる。列と確認の SQL は [plan-pr3.md](./plan-pr3.md#staging--本番への投入メンテナ) +- staging / 本番: メンテナが Supabase の CSV で入れる。列と確認の SQL は [plan.md](./plan.md#staging--本番への投入メンテナ) ## AOJ に後から追加されたとき diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md deleted file mode 100644 index 7bdb36c35..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ /dev/null @@ -1,138 +0,0 @@ -# PR3 — 欠落問題の補完 - -前提と共通の決定は [plan.md](./plan.md)。調べた結果と根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。採らなかった案は、すべて[下の節](#採らなかった案)にまとめた。 - -**状態**: 実装済み、未マージ(2026-10-28。ユニットテスト・lint・check は通過)。残りは、開発サーバーでの目視、Codex のクロスレビュー、マージ後の [CSV 投入](#staging--本番への投入メンテナ)。実装の決定は [done.md](./done.md#pr3) に要約した。 - -## 概要 - -AOJ の JAG / ICPC のコンテストで、公式の問題なのに seed に無い 26 問を足す。PR2 で今ある問題の記号を補正済みなので、足しても今ある記号は変わらない。 - -| 種類 | 問題 | 入れ方 | -| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| AOJ にあるが、取り込み元のコンテスト一覧に無い(5 問) | 1123(ICPC 国内予選 2002 の E)、3643(JAG 夏合宿 2024 Day3 の G)、2565〜2567(JAG 模擬地区 2013 の A〜C) | AOJ の問題 ID のまま、通常の問題として入れる | -| AOJ に無い(21 問) | 2005〜2008 年の JAG の 7 コンテスト([一覧](./investigation.md#aoj-に無い-21-問)) | ダミーの Task として入れ、リンクは jag-icpc.org に向ける | - -管理画面の取り込みは AOJ のコンテスト一覧を使うので、どちらも取り込めない。seed に足し、staging / 本番にはメンテナが Supabase の CSV で入れる。 - -## 決めたこと - -### AOJ にある 5 問 - -- AOJ の問題 ID と、公式の記号で入れる -- 3643 は 2026-10-10 に AOJ に追加されたばかりで、公開前かもしれない(`available` が 4、提出が 0)が、今回入れる - -### AOJ に無い 21 問(ダミー) - -- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま -- **title**: jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記。`Wireing Assistant` も原文どおり -- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる -- **リンクの出し方**: `getTaskUrl()` の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。URL は `task_id` をキーにした map から引く -- **出典のラベル**: `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`) -- **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルで伝える -- **出す場所**: コンテスト別の表・問題一覧・投票・タグ・問題集のすべて。Task のレコードがあれば動く -- **AOJ に後から追加されたら**: 管理画面から取り込まず、DB の `task_id` を AOJ の ID に UPDATE する([手順](./investigation.md#aoj-に後から追加されたとき)) - -変更が要る機能は、問題へのリンクと出典のラベルの 2 つ。影響範囲の全体は [investigation.md](./investigation.md#影響範囲)。 - -## 採らなかった案 - -| 対象 | 採らなかった案 | 理由 | -| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 3643 の入れ方 | AOJ のコンテスト一覧に入るのを待ち、管理画面で取り込む | 合宿系は管理画面の取り込みの対象外なので、一覧に入っても取り込めない | -| ダミーの title | 一覧でしか確かめていない 19 問を、問題文の PDF の表紙でも確かめる | 一覧はスプレッドシートとも一致していて、誤記が残る見込みは小さい | -| ダミーの `task_id` | 数字(100000 番台など) | 既存のコードは壊れないが、AOJ の本物の ID と見分けられない。数字の範囲は AOJ が決める領域(通常の問題は 40xx 番台まで、AOJ 自身のダミーに 9900〜9995)で、衝突の心配が残る | -| ダミーの `task_id` | 50xx 番台 | AOJ の通常の採番がいずれ届く | -| ダミーの `task_id` | `Missing-` | 「欠けている」は AOJ にある 5 問にも当てはまり、意味がずれる。問題文はあるのに、中身の無い仮のレコードやエラーページにも読める | -| ダミーの `task_id` | `jag-icpc_` など出所を表す接頭辞 | 分岐の理由(採点が無い)ではなく、問題文の置き場所を表す | -| リンク先 | PDF への直リンク | 問題文までは 1 クリックだが、講評に辿れない。添付ファイル名が揃っておらず、切れる要因が増える | -| リンク先 | `問題文とデータセット` のページ | 講評に辿れない | -| リンクの出し方 | `getTaskUrl()` の先頭で map を引く | コードは 2〜3 行で済むが、`getTaskUrl()` に特別扱いが入る。判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が割れる。map に漏れがあると AOJ のジェネレータに回り、開けない AOJ のリンクになる | -| リンクの出し方 | Task に `url` 列を足す | マイグレーションが要り、`getTaskUrl()` の呼び出し元 9 ファイルに URL を渡す変更が波及する。21 件には重い。他サイトに対応するときに再検討する | -| リンクの出し方 | URL を年・大会区分・ファイル名から組み立てる | 規則の推測が外れると、黙って間違ったリンクになる。ブラウザで開けた URL をそのまま貼る | -| リンクの出し方 | map を隠し、URL を取り出す関数だけを export する | seed との突き合わせのテストで、map のキーを列挙できない | -| リンクの出し方 | 外部リンクを CI で定期的に確かめる | 外部サイトの状態でテストが落ちるようになる | -| 判定の置き場所 | `isNoJudgeTask()` を `src/lib/utils/` に置く | `src/lib/utils/task.ts` が `#lib/contests/index.js` を import しているので、`lib/contests` の `task_index_label.ts` から使うと循環 import になる | -| 出典のラベル | `taskId` を省略できる引数にする | 渡し忘れた呼び出し元が、黙って `(AOJ)` のままになる。必須なら、型チェックが漏れを見つける | -| 見た目 | セルにアイコンを足す | セルの幅が足りない(AOJ の表は 1 行に最大 6 列で、問題名はもう省略されている) | -| 見た目 | リンクに `title` 属性のツールチップ | 幅は使わないが、スマホでは出ない。知らせる仕組みが必要になったら、最初に検討する | -| 見た目 | 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので意味が混ざり、どの問題が対象かも分からない | -| 見た目 | 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | - -## 実装 - -5 段階(ダミーの判定と URL → URL ジェネレータ → 出典のラベル → seed → 仕上げ)で、テストを先に書いて入れた。内容と計画からの変更は [done.md](./done.md#pr3)。 - -未了: - -- 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と出典のラベルを目で確かめる -- 共有の型(`UrlGenerator`)と汎用関数(`addContestNameToTaskIndex`)のシグネチャを変えたので、Codex でクロスレビューする - -### staging / 本番への投入(メンテナ) - -コードのデプロイの後に CSV を入れる(先に入れると、デプロイまでの間、ダミーが開けない AOJ のリンクと `(AOJ)` のラベルで出る)。 - -- 列は `id`・`contest_type`・`created_at`・`updated_at` も明示する。Supabase の CSV は Prisma の既定値を使わず、`created_at` は DB に既定値があっても空になった例がある(`docs/guides/agent-rules/prisma-db.md`) -- `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする -- CSV の投入は `invalidateTaskCaches()` を経ないので、画面に出るのはタスクのキャッシュの有効期限(1 時間)が切れた後 -- 行の一覧は下の表(seed の 26 行と同じ) - -| `task_id` | `contest_id` | `contest_type` | `task_table_index` | `title` | -| ---------------------------- | ------------------ | -------------- | ------------------ | ---------------------------- | -| 1123 | ICPCPrelim2002 | AOJ_ICPC | E | Enclosing Circles | -| NoJudge-JAGRegional2005_c | JAGRegional2005 | AOJ_JAG | C | Geometry?! Why Not?? | -| NoJudge-JAGRegional2005_f | JAGRegional2005 | AOJ_JAG | F | University Rankings | -| NoJudge-JAGRegional2005_h | JAGRegional2005 | AOJ_JAG | H | Tiling Polygons | -| NoJudge-JAGRegional2008_j | JAGRegional2008 | AOJ_JAG | J | Colony Maintenance | -| 2565 | JAGRegional2013 | AOJ_JAG | A | Broken Audio Signal | -| 2566 | JAGRegional2013 | AOJ_JAG | B | Restore Calculation | -| 2567 | JAGRegional2013 | AOJ_JAG | C | SIRO Challenge | -| NoJudge-JAGSummer2006-day1_a | JAGSummer2006-day1 | AOJ_JAG | A | Monkey Business | -| NoJudge-JAGSummer2006-day1_c | JAGSummer2006-day1 | AOJ_JAG | C | Sort that Queue | -| NoJudge-JAGSummer2006-day3_a | JAGSummer2006-day3 | AOJ_JAG | A | Rolling Cubes | -| 3643 | JAGSummer2024-day3 | AOJ_JAG | G | Interactive String Guessing | -| NoJudge-JAGWinter2006-day2_a | JAGWinter2006-day2 | AOJ_JAG | A | Cells | -| NoJudge-JAGWinter2006-day2_b | JAGWinter2006-day2 | AOJ_JAG | B | Japan | -| NoJudge-JAGWinter2006-day2_c | JAGWinter2006-day2 | AOJ_JAG | C | Maximum | -| NoJudge-JAGWinter2006-day2_e | JAGWinter2006-day2 | AOJ_JAG | E | Payment System | -| NoJudge-JAGWinter2006-day2_g | JAGWinter2006-day2 | AOJ_JAG | G | Roof | -| NoJudge-JAGWinter2006-day2_h | JAGWinter2006-day2 | AOJ_JAG | H | Seminar Room | -| NoJudge-JAGWinter2006-day2_i | JAGWinter2006-day2 | AOJ_JAG | I | String Compression | -| NoJudge-JAGWinter2006-day3_b | JAGWinter2006-day3 | AOJ_JAG | B | Widget Factory | -| NoJudge-JAGWinter2006-day3_c | JAGWinter2006-day3 | AOJ_JAG | C | Hills | -| NoJudge-JAGWinter2006-day3_e | JAGWinter2006-day3 | AOJ_JAG | E | Safe Packing | -| NoJudge-JAGWinter2006-day3_f | JAGWinter2006-day3 | AOJ_JAG | F | The "Shortest" Pair of Paths | -| NoJudge-JAGWinter2006-day3_i | JAGWinter2006-day3 | AOJ_JAG | I | Counting Triangles | -| NoJudge-JAGWinter2006-day3_j | JAGWinter2006-day3 | AOJ_JAG | J | Wireing Assistant | -| NoJudge-JAGWinter2007-day3_e | JAGWinter2007-day3 | AOJ_JAG | E | Networking Company | - -CSV を入れたら、次の SQL で記号を確かめる。`(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える。seed はテストが守るが、CSV は守らない。 - -```sql --- Expect no rows: a duplicated letter hides one problem in the contest table. -SELECT contest_id, task_table_index, COUNT(*) -FROM task -WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') -GROUP BY contest_id, task_table_index -HAVING COUNT(*) > 1; - --- Expect the letters in the table below. -SELECT contest_id, COUNT(*) AS task_count, STRING_AGG(task_table_index, '' ORDER BY task_table_index) AS letters -FROM task -WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') -GROUP BY contest_id -ORDER BY contest_id; -``` - -| `contest_id` | 問題数 | 記号 | -| ------------------ | ------ | ----------- | -| ICPCPrelim2002 | 5 | ABCDE | -| JAGRegional2005 | 9 | ABCDEFGHI | -| JAGRegional2008 | 10 | ABCDEFGHIJ | -| JAGRegional2013 | 10 | ABCDEFGHIJ | -| JAGSummer2006-day1 | 3 | ABC | -| JAGSummer2006-day3 | 7 | ABCDEFG | -| JAGSummer2024-day3 | 11 | ABCDEFGHIJK | -| JAGWinter2006-day2 | 9 | ABCDEFGHI | -| JAGWinter2006-day3 | 10 | ABCDEFGHIJ | -| JAGWinter2007-day3 | 10 | ABCDEFGHIJ | diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index 76ff84eda..2e16898f0 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -2,12 +2,10 @@ **日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装済み、未マージ。残りは [残りのタスク](#残りのタスク) -このファイルは全体像と、PR をまたぐ決定・手順だけを書く。各 PR の作業はそれぞれのファイルにある。 +このファイルに、全体像、PR をまたぐ決定・手順、PR3 の設計、完了分の決定、残りのタスクをまとめた。根拠データは investigation.md にある。 | ファイル | 内容 | | -------------------------------------- | -------------------------------------------------- | -| [plan-pr3.md](./plan-pr3.md) | PR3: 欠落問題の補完(設計・CSV 投入の手順) | -| [done.md](./done.md) | PR0〜PR3 の決定の要約と、PR 外の残タスク | | [investigation.md](./investigation.md) | 根拠データ。PR2 の照合結果、表示順、欠落の一覧など | ## ゴール @@ -30,7 +28,7 @@ | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632) | 記号 | 素 | 記号を付ける | あり | | JAG 夏 / 冬 / 春合宿・模擬地区・in AtCoder(76 コンテスト) | 記号 | 素 | 合宿系は経路が無い。模擬地区は記号を付ける | 「JAG 模擬地区」にコンテストごと | -経緯(詳細は [done.md](./done.md)): +経緯(詳細は[完了分の詳細](#完了分の詳細pr0pr3)): - PR1.5(#4219): AOJ の API は 2026-09-07 に別のホストへ移り、旧ホストは 410 を返す。追従した - PR2(#4220): 合宿系と模擬地区を記号にし、JAG は全 round で取り込みでも記号を付けるようにした @@ -40,40 +38,47 @@ ## PR の分割と順序 -| | 範囲 | コンテスト | 問題数 | 内容 | -| -------------------- | ------------------------------------------------------- | ---------- | ------------------------- | ------------------------------------------------------------------------------------ | -| [PR1.5](./done.md) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。完了(#4219) | -| [PR2](./done.md) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする。staging に反映(#4220) | -| [PR2.1](./done.md) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む)。staging に反映(#4222) | -| [PR2.5](./done.md) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」に、コンテスト別のテーブルを作る。実装済み、未マージ | -| [PR3](./plan-pr3.md) | 欠落問題の補完 | 10 | 26 | AOJ のコンテスト一覧に無い問題を足す(AOJ に無い 21 問はダミー)。実装済み、未マージ | +| | 範囲 | コンテスト | 問題数 | 内容 | +| ---------------------------- | ------------------------------------------------------- | ---------- | ------------------------- | ------------------------------------------------------------------------------------ | +| [PR1.5](#完了分の詳細pr0pr3) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。完了(#4219) | +| [PR2](#完了分の詳細pr0pr3) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする。staging に反映(#4220) | +| [PR2.1](#完了分の詳細pr0pr3) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む)。staging に反映(#4222) | +| [PR2.5](#完了分の詳細pr0pr3) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」に、コンテスト別のテーブルを作る。実装済み、未マージ | +| [PR3](#pr3-欠落問題の補完) | 欠落問題の補完 | 10 | 26 | AOJ のコンテスト一覧に無い問題を足す(AOJ に無い 21 問はダミー)。実装済み、未マージ | PR1 と PR2 で、記号にするのは 1,257 問 / 145 コンテストになる。PR2 で完結し、PR3 に進まなくても機能として成立する。 - **PR1.5 → staging で JAG REGIONAL を取り込む → PR2** の順で進めた(済)。PR2 の件数チェックを staging で通すには、staging の模擬地区を本番と同じ 16 / 158 にそろえる必要があったため - **PR2 → PR2.5 は守る**。Provider を先に出すと、合宿系の数値の index と `'2389. …'` の title がそのまま表に出る - **PR2.1 は PR2 と前後してよい**。PR2 の移行 SQL は index が問題 ID のままの行しか書き換えない -- **PR2.5 のコードは PR2.1 の後に出す**。AtCoder の 4 コンテストの Provider に `ATCODER_JAG` を使い、seed との整合テストも PR2.1 の seed を前提にする(計画では PR2.1 と独立させていたが、実装時に変えた。[理由](./done.md#分類と表示))。本番の PR2.1 のデータ投入は PR2.5 の後でもよいが、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(`TaskTable.svelte` は、データの無い Provider でも見出しと枠を描画する。[残タスク](./done.md#残タスク)) +- **PR2.5 のコードは PR2.1 の後に出す**。AtCoder の 4 コンテストの Provider に `ATCODER_JAG` を使い、seed との整合テストも PR2.1 の seed を前提にする(計画では PR2.1 と独立させていたが、実装時に変えた。[理由](#分類と表示))。本番の PR2.1 のデータ投入は PR2.5 の後でもよいが、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(`TaskTable.svelte` は、データの無い Provider でも見出しと枠を描画する。[残タスク](#pr-外の残タスク)) - **PR3 は独立**。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない -### 残りのタスク +## 残りのタスク -PR3(実装済み): +### PR3(実装済み、未マージ) + +ユニットテスト・lint・check は通過し、Codex のクロスレビューも済んだ(指摘はテストの強化 1 件で、反映済み)。 1. 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と `(No Judge)` を目で確かめる -2. Codex でクロスレビューする(共有の型と汎用関数のシグネチャを変えたため) -3. PR を開き、staging へマージする -4. コードのデプロイの後に、メンテナが staging へ CSV(26 行)を投入し、[SQL](./plan-pr3.md#staging--本番への投入メンテナ) で記号を確かめる -5. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる +2. PR を開き、staging へマージする +3. コードのデプロイの後に、メンテナが staging へ CSV(26 行)を投入し、[SQL](#staging--本番への投入メンテナ) で記号を確かめる +4. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる + +### PR 外の残タスク -PR 外の残タスクは [done.md](./done.md#残タスク)(2016 年の模擬国内の取り込み、seed 整合テストの期待値、`TaskTable.svelte` の空の枠、名称の統一 #4018、`title.replace` の置換)。ほかに次の 2 件がある。 +PR2 以降の計画には含めていない。 -- AOJ Courses / PCK / 大学系は、数値の index のまま残す(下の[スコープ外](#スコープ外)) -- 本番の title の前後の空白 46 件は、実害が無いので直さない(下記) +- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う(Codex / CodeRabbit)。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る。`getContests` 側もそろえる +- seed 整合テストの期待値を `assignProblemLetters` で作っている(PR1 と PR2.1 の Codex)。testing.md の「期待値は literal」に合わない。PR2 では補正したコンテストだけを literal にする(M18)。全件を literal にするかは未判断 +- `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する(PR2.5 の Codex)。`prepareContestTable` が `null` を返して消えるのは行だけ。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので PR2.5 では直さなかった(メンテナの判断) +- テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める。PR2.5 の配列(並びと括弧書き)が移行元になる([名称の違い](./investigation.md#名称の違い)) +- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する(Codex)。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める +- 本番の title の前後の空白 46 件は、実害が無いので直さない([下記](#スコープ外)) ### スコープ外 -次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。PR1 から残っているタスクは [done.md](./done.md#残タスク)。 +次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。PR1 から残っているタスクは[PR 外の残タスク](#pr-外の残タスク)。 本番の title のうち 46 件は前後に空白がある(AOJ 4 件、AtCoder 42 件。[詳細](./investigation.md#前後に空白のある-title))。実害が無く再発もしないので、本件では直さず残タスクとする。直すときは、1 本の UPDATE(`btrim`、46 件の件数チェック付き)で済む。 @@ -95,7 +100,7 @@ PR 外の残タスクは [done.md](./done.md#残タスク)(2016 年の模擬 ## 決定事項 -番号は PR0 からの通し番号。PR0 / PR1 で完了した M4 / M7 / M10 / M11 は [done.md](./done.md)。 +番号は PR0 からの通し番号。PR0 / PR1 で完了した M4 / M7 / M10 / M11 は[完了分の詳細](#完了分の詳細pr0pr3)。 | # | PR | 決定 | 理由 | | --- | ----- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -173,12 +178,12 @@ PR2(記号の移行)と PR2.1(enum の追加)、PR3 に当てはまる | **バックアップ** | Supabase Pro のため PITR が利用可。復元トリックが効くのは一度目だけなので、**再修正時は PITR が唯一の手段** | | **`updated_at`** | `@updatedAt` は Prisma Client 側の実装で DB トリガーではない。生 SQL では更新されない。**意図的に含めない** | | **キャッシュ** | TTL 1 時間(`src/lib/clients/cache.ts`)。生 SQL と CSV の投入は `invalidateTaskCaches()` を経由しない。「更新したのに表示が変わらない」ときはこれを疑う | -| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される([done.md](./done.md#移行-sql)) | +| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される([移行 SQL](#移行-sql)) | | **デプロイ直前の数分** | マイグレーションの適用から Vercel のデプロイ完了までの間は、旧コードが新しいデータを描画する。数分で解消するので**許容する** | ### スプレッドシートとの照合 -対応表は次の手順で作る(M6)。PR1 のスクリプトを直して使う(仕組みは [done.md](./done.md#照合記号の対応表))。 +対応表は次の手順で作る(M6)。PR1 のスクリプトを直して使う(仕組みは [照合](#照合記号の対応表))。 1. 基本ルールで対象範囲の記号を生成する 2. コンテスト単位で、メンテナのスプレッドシートと照合する ← 人手が要るのはここだけ(書き写し) @@ -188,3 +193,306 @@ PR2(記号の移行)と PR2.1(enum の追加)、PR3 に当てはまる > **注意**: 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても表示は成立してしまう(前詰めでずれた記号が出るだけ)。 **スクリプトには総数のアサーションを入れる。** 抽出漏れが欠落の誤検出に直結する(調査中に実際に踏んだ。[事例](./investigation.md#6-調査中の誤りと訂正自戒))。抽出件数が対象範囲の既知の値と一致しなければエラーで止め、欠落として扱わない。 + +## PR3: 欠落問題の補完 + +根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。採らなかった案は、すべてこの節にまとめた。 + +### 概要 + +AOJ の JAG / ICPC のコンテストで、公式の問題なのに seed に無い 26 問を足す。PR2 で今ある問題の記号を補正済みなので、足しても今ある記号は変わらない。 + +| 種類 | 問題 | 入れ方 | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| AOJ にあるが、取り込み元のコンテスト一覧に無い(5 問) | 1123(ICPC 国内予選 2002 の E)、3643(JAG 夏合宿 2024 Day3 の G)、2565〜2567(JAG 模擬地区 2013 の A〜C) | AOJ の問題 ID のまま、通常の問題として入れる | +| AOJ に無い(21 問) | 2005〜2008 年の JAG の 7 コンテスト([一覧](./investigation.md#aoj-に無い-21-問)) | ダミーの Task として入れ、リンクは jag-icpc.org に向ける | + +管理画面の取り込みは AOJ のコンテスト一覧を使うので、どちらも取り込めない。seed に足し、staging / 本番にはメンテナが Supabase の CSV で入れる。 + +### 決めたこと + +#### AOJ にある 5 問 + +- AOJ の問題 ID と、公式の記号で入れる +- 3643 は 2026-10-10 に AOJ に追加されたばかりで、公開前かもしれない(`available` が 4、提出が 0)が、今回入れる + +#### AOJ に無い 21 問(ダミー) + +- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま +- **title**: jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記。`Wireing Assistant` も原文どおり +- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる +- **リンクの出し方**: `getTaskUrl()` の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。URL は `task_id` をキーにした map から引く +- **出典のラベル**: `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`) +- **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルで伝える +- **出す場所**: コンテスト別の表・問題一覧・投票・タグ・問題集のすべて。Task のレコードがあれば動く +- **AOJ に後から追加されたら**: 管理画面から取り込まず、DB の `task_id` を AOJ の ID に UPDATE する([手順](./investigation.md#aoj-に後から追加されたとき)) + +変更が要る機能は、問題へのリンクと出典のラベルの 2 つ。影響範囲の全体は [investigation.md](./investigation.md#影響範囲)。 + +### 採らなかった案 + +| 対象 | 採らなかった案 | 理由 | +| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 3643 の入れ方 | AOJ のコンテスト一覧に入るのを待ち、管理画面で取り込む | 合宿系は管理画面の取り込みの対象外なので、一覧に入っても取り込めない | +| ダミーの title | 一覧でしか確かめていない 19 問を、問題文の PDF の表紙でも確かめる | 一覧はスプレッドシートとも一致していて、誤記が残る見込みは小さい | +| ダミーの `task_id` | 数字(100000 番台など) | 既存のコードは壊れないが、AOJ の本物の ID と見分けられない。数字の範囲は AOJ が決める領域(通常の問題は 40xx 番台まで、AOJ 自身のダミーに 9900〜9995)で、衝突の心配が残る | +| ダミーの `task_id` | 50xx 番台 | AOJ の通常の採番がいずれ届く | +| ダミーの `task_id` | `Missing-` | 「欠けている」は AOJ にある 5 問にも当てはまり、意味がずれる。問題文はあるのに、中身の無い仮のレコードやエラーページにも読める | +| ダミーの `task_id` | `jag-icpc_` など出所を表す接頭辞 | 分岐の理由(採点が無い)ではなく、問題文の置き場所を表す | +| リンク先 | PDF への直リンク | 問題文までは 1 クリックだが、講評に辿れない。添付ファイル名が揃っておらず、切れる要因が増える | +| リンク先 | `問題文とデータセット` のページ | 講評に辿れない | +| リンクの出し方 | `getTaskUrl()` の先頭で map を引く | コードは 2〜3 行で済むが、`getTaskUrl()` に特別扱いが入る。判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が割れる。map に漏れがあると AOJ のジェネレータに回り、開けない AOJ のリンクになる | +| リンクの出し方 | Task に `url` 列を足す | マイグレーションが要り、`getTaskUrl()` の呼び出し元 9 ファイルに URL を渡す変更が波及する。21 件には重い。他サイトに対応するときに再検討する | +| リンクの出し方 | URL を年・大会区分・ファイル名から組み立てる | 規則の推測が外れると、黙って間違ったリンクになる。ブラウザで開けた URL をそのまま貼る | +| リンクの出し方 | map を隠し、URL を取り出す関数だけを export する | seed との突き合わせのテストで、map のキーを列挙できない | +| リンクの出し方 | 外部リンクを CI で定期的に確かめる | 外部サイトの状態でテストが落ちるようになる | +| 判定の置き場所 | `isNoJudgeTask()` を `src/lib/utils/` に置く | `src/lib/utils/task.ts` が `#lib/contests/index.js` を import しているので、`lib/contests` の `task_index_label.ts` から使うと循環 import になる | +| 出典のラベル | `taskId` を省略できる引数にする | 渡し忘れた呼び出し元が、黙って `(AOJ)` のままになる。必須なら、型チェックが漏れを見つける | +| 見た目 | セルにアイコンを足す | セルの幅が足りない(AOJ の表は 1 行に最大 6 列で、問題名はもう省略されている) | +| 見た目 | リンクに `title` 属性のツールチップ | 幅は使わないが、スマホでは出ない。知らせる仕組みが必要になったら、最初に検討する | +| 見た目 | 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので意味が混ざり、どの問題が対象かも分からない | +| 見た目 | 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | + +#### staging / 本番への投入(メンテナ) + +コードのデプロイの後に CSV を入れる(先に入れると、デプロイまでの間、ダミーが開けない AOJ のリンクと `(AOJ)` のラベルで出る)。 + +- 列は `id`・`contest_type`・`created_at`・`updated_at` も明示する。Supabase の CSV は Prisma の既定値を使わず、`created_at` は DB に既定値があっても空になった例がある(`docs/guides/agent-rules/prisma-db.md`) +- `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする +- CSV の投入は `invalidateTaskCaches()` を経ないので、画面に出るのはタスクのキャッシュの有効期限(1 時間)が切れた後 +- 行の一覧は下の表(seed の 26 行と同じ) + +| `task_id` | `contest_id` | `contest_type` | `task_table_index` | `title` | +| ---------------------------- | ------------------ | -------------- | ------------------ | ---------------------------- | +| 1123 | ICPCPrelim2002 | AOJ_ICPC | E | Enclosing Circles | +| NoJudge-JAGRegional2005_c | JAGRegional2005 | AOJ_JAG | C | Geometry?! Why Not?? | +| NoJudge-JAGRegional2005_f | JAGRegional2005 | AOJ_JAG | F | University Rankings | +| NoJudge-JAGRegional2005_h | JAGRegional2005 | AOJ_JAG | H | Tiling Polygons | +| NoJudge-JAGRegional2008_j | JAGRegional2008 | AOJ_JAG | J | Colony Maintenance | +| 2565 | JAGRegional2013 | AOJ_JAG | A | Broken Audio Signal | +| 2566 | JAGRegional2013 | AOJ_JAG | B | Restore Calculation | +| 2567 | JAGRegional2013 | AOJ_JAG | C | SIRO Challenge | +| NoJudge-JAGSummer2006-day1_a | JAGSummer2006-day1 | AOJ_JAG | A | Monkey Business | +| NoJudge-JAGSummer2006-day1_c | JAGSummer2006-day1 | AOJ_JAG | C | Sort that Queue | +| NoJudge-JAGSummer2006-day3_a | JAGSummer2006-day3 | AOJ_JAG | A | Rolling Cubes | +| 3643 | JAGSummer2024-day3 | AOJ_JAG | G | Interactive String Guessing | +| NoJudge-JAGWinter2006-day2_a | JAGWinter2006-day2 | AOJ_JAG | A | Cells | +| NoJudge-JAGWinter2006-day2_b | JAGWinter2006-day2 | AOJ_JAG | B | Japan | +| NoJudge-JAGWinter2006-day2_c | JAGWinter2006-day2 | AOJ_JAG | C | Maximum | +| NoJudge-JAGWinter2006-day2_e | JAGWinter2006-day2 | AOJ_JAG | E | Payment System | +| NoJudge-JAGWinter2006-day2_g | JAGWinter2006-day2 | AOJ_JAG | G | Roof | +| NoJudge-JAGWinter2006-day2_h | JAGWinter2006-day2 | AOJ_JAG | H | Seminar Room | +| NoJudge-JAGWinter2006-day2_i | JAGWinter2006-day2 | AOJ_JAG | I | String Compression | +| NoJudge-JAGWinter2006-day3_b | JAGWinter2006-day3 | AOJ_JAG | B | Widget Factory | +| NoJudge-JAGWinter2006-day3_c | JAGWinter2006-day3 | AOJ_JAG | C | Hills | +| NoJudge-JAGWinter2006-day3_e | JAGWinter2006-day3 | AOJ_JAG | E | Safe Packing | +| NoJudge-JAGWinter2006-day3_f | JAGWinter2006-day3 | AOJ_JAG | F | The "Shortest" Pair of Paths | +| NoJudge-JAGWinter2006-day3_i | JAGWinter2006-day3 | AOJ_JAG | I | Counting Triangles | +| NoJudge-JAGWinter2006-day3_j | JAGWinter2006-day3 | AOJ_JAG | J | Wireing Assistant | +| NoJudge-JAGWinter2007-day3_e | JAGWinter2007-day3 | AOJ_JAG | E | Networking Company | + +CSV を入れたら、次の SQL で記号を確かめる。`(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える。seed はテストが守るが、CSV は守らない。 + +```sql +-- Expect no rows: a duplicated letter hides one problem in the contest table. +SELECT contest_id, task_table_index, COUNT(*) +FROM task +WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') +GROUP BY contest_id, task_table_index +HAVING COUNT(*) > 1; + +-- Expect the letters in the table below. +SELECT contest_id, COUNT(*) AS task_count, STRING_AGG(task_table_index, '' ORDER BY task_table_index) AS letters +FROM task +WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') +GROUP BY contest_id +ORDER BY contest_id; +``` + +| `contest_id` | 問題数 | 記号 | +| ------------------ | ------ | ----------- | +| ICPCPrelim2002 | 5 | ABCDE | +| JAGRegional2005 | 9 | ABCDEFGHI | +| JAGRegional2008 | 10 | ABCDEFGHIJ | +| JAGRegional2013 | 10 | ABCDEFGHIJ | +| JAGSummer2006-day1 | 3 | ABC | +| JAGSummer2006-day3 | 7 | ABCDEFG | +| JAGSummer2024-day3 | 11 | ABCDEFGHIJK | +| JAGWinter2006-day2 | 9 | ABCDEFGHI | +| JAGWinter2006-day3 | 10 | ABCDEFGHIJ | +| JAGWinter2007-day3 | 10 | ABCDEFGHIJ | + +## 完了分の詳細(PR0〜PR3) + +コードと git の履歴から読み取れない判断と事実だけを、項目ごとにまとめる。変更したファイルや関数は各 PR の差分を見る。 + +### 進め方 + +| 決定 | PR | 理由 | +| ---------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PR0 → PR1 → 合宿系の順にする | 全体 | PR0 は DB に依存しない。PR1 は出典欄の成果がすぐ見え、欠落が少ないので対応表の形式を楽なケースで決められる。逆順はレビュー負荷が低いが、出典欄の反映が遅れ、対応表の形式を最も判断の重い合宿系で作ることになる | + +### 移行 SQL + +| 決定 | PR | 理由 | +| ----------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 対応表を焼き込まず、基本ルールを SQL で表す | PR1 | 本番のタスクは管理画面の取り込みで入っており、seed と一致する保証が無い。`VALUES` で書くと本番にしか無い行が残り、`UPDATE` は該当行が無くてもエラーにならないので、取りこぼしが静かに起きる | +| 基本ルールで振り、8 コンテストはコンテストの全行を上書きする(45 行) | PR2 | 8 コンテストは先頭や途中の問題が AOJ に無く、基本ルールでは記号がずれる。正しい記号は、メンテナのスプレッドシートの位置(欠けた問題を飛ばした位置)で決まる。全行を上書きするのは、1 つのコンテストに 2 つの規則を混ぜないため | +| 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る | PR1 / PR2 | 合宿系も模擬国内と同じ `AOJ_JAG` に分類される。PR1 で `contest_type` だけで絞ると合宿系 467 件まで更新し、title のプレフィックスが表に出る。PR2 は `^JAG(Summer\|Winter\|Spring\|Regional)` で絞り、PR1 で記号にした模擬国内を外す | +| 各ステップの更新件数を DO ブロックで検査し、想定外なら RAISE する | PR2 | UPDATE は対象が 0 件でもエラーにならない。想定値は、空の DB(`prisma migrate reset`)の 0、模擬地区の無い開発 DB の 467 / 32 / 467、staging・本番の 625 / 45 / 467 | +| 件数は固定値のまま。模擬地区が一部だけの DB では RAISE し、エラー文で「全部取り込むか、全部消す」と案内する | PR2 | 本番で範囲の誤りを止める力を保つため。模擬地区だけ実際の件数に合わせる案は、範囲の誤りを見逃しやすくなるので採らなかった | +| 順位はコンテストの全行で付け、更新は問題 ID のままの行だけにする | PR2 | 後から記号付きで入る行(PR2.1)が先にあっても触らないため。順位を付ける側でも絞ると、記号の行と問題 ID の行が混ざったコンテストで A から振り直してしまう(Codex の指摘) | +| title から外すのは「自分の `task_id` + `. `」で始まるものだけ | PR2 | `^[0-9]+\. ` で外すと、番号で始まる本来の title まで削りうる | +| `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない | PR1 | 本番でも index は既に正しい。AOJ に 1 問しか無いので、大学系に範囲を広げるときは基本ルール(`A` になる)の対象から外す | + +分かったこと: + +- **SQL**(PR1): `row_number()` は `bigint` を返し、`chr(bigint)` は存在しないので `::int` が要る。`EXPLAIN` は `task` の Seq Scan 2 回 + Hash Join(`contest_type` に索引が無いため想定どおり) +- **トランザクション**(PR1): マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで、1–2 文目も残らないことをローカルで確認) +- **ローカルでの移行**(PR2): 管理画面で模擬地区を取り込み、本番と同じ状態にしてから確かめた。`SELECT` のプレビューは 625 行すべてで照合の結果と一致し、適用後もプレビューと 625 / 625 で一致した。範囲外の AOJ(PCK、大学系、PR1 の範囲)は変わらない。`EXPLAIN` は CTE 側の Seq Scan 1 回と、`task_task_id_key` の Index Scan を使う Nested Loop +- **Codex の指摘の再現**(PR2): 記号の行と問題 ID の行が混ざったコンテストを読み取り専用で再現すると、直す前の SQL は A から振り直し、直した後は B, C, D と正しく振った。ローカルは移行前のスナップショットに戻し、直した移行を適用し直した(625 / 625)。「`task_id` だけで結合している」という指摘は、`task_id` が schema で `@unique` なので該当しない +- **staging**(PR2): 当初は模擬地区が 2 コンテスト / 16 問しか無く、件数チェックで RAISE するところだった。PR1.5 の後に staging で取り込み、PR を開く前に本番と同じ状態(模擬地区 16 / 158、合宿系 50 / 467、index はすべて問題 ID、title の問題 ID は合宿系だけ)であることを確かめた。#4220 で反映した(2026-10-25)。移行の後に Vercel のデプロイが走るので、件数チェックも通っている + +### 照合(記号の対応表) + +- **PR1**: メンテナのスプレッドシートを 2026-10-21 のスクリーンショットから書き写し、632 問すべてが基本ルールの記号の位置で一致した(補正なし)。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に 1123 として存在するが、コンテスト一覧に無いので取り込まれていない(末尾なので記号はずれない。PR3 の対象) +- **PR2**: スプレッドシートの書き写し(66 コンテスト)と 625 問の title が、記号の位置ですべて一致した。補正は 8 コンテスト / 45 行(事前の目視どおり) +- **PR2.1**: 114 問すべてが、スプレッドシートの問題名と記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で欠落が無く、基本ルールと一致する +- **照合スクリプト**: PR1 は `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`、PR2 は `git show c605d3bd:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_jag_problem_letters.ts`。`prisma/tasks.ts` を import し、件数が既知の値と違えば止まる。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。スプレッドシート自体の誤りは検出できない +- **スクリプトの検出力**: わざと誤りを入れたコピーを、PR1 / PR2 とも 5 種類すべて検出した。PR1 は順番の入れ替え、途中の title の誤り、途中の欠番、問題数の不足、切れた title の前半の誤り。PR2 は欠けた記号を外す、順番の入れ替え、途中の title の誤り、欠けた数の不足、切れた title の前半の誤り + +### 取り込み(管理画面・AOJ API) + +| 決定 | PR | 理由 | +| ---------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 取り込みでは、対応表ではなく基本ルールで記号を付ける(M5) | PR1 | 対応表を AOJ client に置く案は、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる | +| 取り込みでは補正しない(M14) | PR2 | 8 コンテストは本番・staging・seed のすべてにあり、取り込みは既存の問題を skip する。Codex も「取り込みで補正すべき」と指摘したが、同じ理由で採らなかった | +| 合宿系は管理画面の取り込みに足さない | PR2.1 | 合宿系も AOJ API の JAG の filter(summer / winter / spring)にあるが、既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加には重い。応答の形は意図的に確かめていない(メンテナの判断) | +| AOJ API はベース URL だけを変える | PR1.5 | AOJ Developers の告知(2026-09-07 に Main API が移転)で変わったのはホストだけ。メンテナが新しい API をブラウザで開き、取り込みが使う 8 つのエンドポイント(`courses`、`problems?size=10000`、`challenges` の 6 種別)のパスと応答の形が fixture と同じことを確かめた | + +分かったこと: + +- **2 日開催の JAG 模擬国内**(PR1): AOJ API には `JAGPrelim20162`(2738–2751)のように 2 日を 1 コンテストにしたものがある。取り込みは `contest_id` 単位で振るので A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存の問題を skip するので影響しない +- **応答の違い**(PR1.5): 値の違いは最上位の `largeCl.id` が小文字になったこと(`"JAG"` → `"jag"` など)だけ。コードはここを読まない。fixture に無い回(ICPC 国内予選 2026、JAG 模擬国内 2026、PCK 予選 2026 など)は、fixture の記録(2026-05)の後に AOJ に追加されたもの +- **ベース URL の落とし穴**(PR1.5): `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった +- **問題ページへのリンク**(PR1.5): `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける(メンテナが確認) +- **staging(Vercel)での取り込み**(PR1.5): 2026-10-24 にメンテナが JAG REGIONAL を取り込み、16 / 158 になった(PR2 の前提)。こちらの取得ツールが新しいホストに `ECONNREFUSED` で拒まれたことから送信元の制限を心配していたが、Vercel からは問題なく取得できた + +### 分類と表示 + +| 決定 | PR | 理由 | +| ------------------------------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AOJ ラベルから全角括弧を外す(M11) | PR0 | 出典欄の目標書式ではコンテスト名を括弧で囲まない。ラベルは検索・投票・タグ・取り込み画面・提出状況の更新モーダルでも使われており、その表示も変わるが許容した | +| 表示側の変換と `getTaskLabels` を削除する(M7 / M10) | PR1 | DB が記号を持てば変換は恒等になる。インターフェイスの `getTaskLabels` だけを残す案は、上書きする Provider が 0 になり使われないコードが残るので採らなかった | +| ヘッダーの並びは `localeCompare`(M4) | PR1 | 記号に `Number()` を当てると NaN になり、ソートが効かなくなる | +| `buildTitleWithTaskIndex` は「外してから付ける」 | PR1 | AtCoder 側の title は `A. …` を含むので、付けるだけだと二重になる。title にプレフィックスの無い AtCoder の問題は `abs` の practice_1 だけで(本番も同じ)、ABS は index を表示しない | +| `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す(レビュー) | PR1 | 記号は title の本文にも現れうる。以前は index が空のとき `Dr. Podboq` → `DrPodboq` のように本文の `. ` まで消していた | +| `ATCODER_JAG` を新設し、4 つを contest_id の完全一致で分類する(M17) | PR2.1 | `jag` の前方一致だと、AtCoder Problems の `JAG2013Spring` / `JAG2014Spring`(AOJ の `JAGSpring2013` / `2014` と同じコンテスト)まで入り、問題が二重になる。`OTHERS` に入れないのはメンテナの判断。`AOJ_JAG` だと出典欄の `(AOJ)` や `getTaskUrl` が AOJ を前提に動く | +| ラベル(出典欄)に(有志)を付けない | PR2.1 | PR2.5 のテーブル名でだけ、2018 Day2 / 2017 Day1 に付ける(M16) | +| 合宿系・模擬地区・AtCoder 上の JAG を 1 グループにし、テーブルはコンテストごとにする(M15) | PR2.5 | 同じ年の中の前後関係(模擬地区 2014 は夏合宿 2014 の Day3 として開催)を表せるのは 1 グループだけ。既存の「JAG 模擬国内」とは統合しない(年のループで完成しており、統合するとそれも配列に置き換えることになる) | +| 並びは明示的な配列で持つ | PR2.5 | スプレッドシートの行順(新しい順)は contest_id から導けない(冬合宿 2010 は夏合宿 2011 の後、など)。今後の合宿は seed と配列の両方に足す(配列の書き漏れは seed との整合テストで落ちる) | +| テーブル名は `getContestNameLabel` の名称に括弧書きを付け、出典欄は変えない(M16) | PR2.5 | スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にすると、出典欄と食い違う。出典欄もそろえると、ラベルを使う検索・投票・タグなどと fixture に波及するので #4018 に回した。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない | +| Provider の `contestType` は、AtCoder 上の 4 コンテストだけ `ATCODER_JAG`、ほかは `AOJ_JAG` | PR2.5 | 計画では、PR2.1 と順序を問わないように全件 `AOJ_JAG` にしていた。PR2.1 が先に staging に入ったので、実際の分類に合わせた(メンテナの判断)。`contestType` が効くのは provider key と `getTaskTableHeaderName`(違いは ABC の H/Ex だけ)なので、表示は変わらない | +| グループのキーは `aojJagRegional` | PR2.5 | キーは localStorage(`contest_table_providers`)に保存され、後から変えると利用者の選択が消える。`aojJagPrelim` と対にし、配列のファイル・型・定数(`aoj_jag_regional_entries.ts`)とクラス(同じファイルの `JagPrelimProvider` と対の `JagRegionalProvider`)もこの名前にそろえた(メンテナの判断) | + +分かったこと: + +- **表示の回帰**(PR1): 旧コードで適用前のデータを、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 +- **Codex のレビュー**(PR2.5): 指摘は 2 件で、上の空の枠(残タスクへ)と、plan.md の「PR2.1 は PR2.5 と前後してよい」が古いこと(直した)。Codex が確かめられなかった 2 点はこちらで確かめた。AtCoder の 4 コンテストのセルは AtCoder に飛ぶ(`getTaskUrl` が AOJ に振るのは大文字の `JAG…` だけ)。title の記号は二重にならない(`buildTitleWithTaskIndex` が先頭の `A. ` を外してから付ける) +- **DB の `contest_type`**(PR2.1): 書き込むだけで、表示などで読む箇所は無い(分類は毎回 `contest_id` から引く)。enum の値は `ADD VALUE` で DB 上の末尾に足され、schema.prisma の並びと違うが、並べ替えにも使っていない + +### seed とデータの投入 + +| 決定 | PR | 理由 | +| ------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 前後に空白のある title(本番 46 件、3355 の末尾のタブを含む)は直さない | PR2 | 表示では空白が潰れ、完全一致で比べるコードも無い。取り込みが trim するので再発もしない([スコープ外](#スコープ外)) | +| staging / 本番の 2025 / 2026 夏合宿は、PR2 の移行の後に Supabase の CSV で記号付きで入れる | PR2.1 | 移行の前に問題 ID のまま入れると、PR2 の件数の想定値が変わる。記号付きなら移行と独立する | +| CSV の `id` は既存の合宿系と同じ `sha256(contest_id + title)` | PR2.1 | 取り込みの `sha256(contest_id + task_id)` とは違うが、`Task.id` を再計算して引く箇所が無い([根拠](./investigation.md#id-の採番規則が経路ごとに違う)) | +| seed の AOJ 72 行は古い順・問題 ID の昇順に置く | PR2.1 | add-tasks の規則(新しい順・降順)は AtCoder 側の慣習で、既存の AOJ のブロックはすべて昇順。Codex の指摘は採らなかった | + +分かったこと: + +- **seed の模擬地区**(PR2): 本番から取り出した 158 問は、AOJ の fixture(2026-05 記録)と問題 ID・コンテスト・名前がすべて一致した +- **ローカルの取り込み**(PR2.1): 2026-10-25 にメンテナが AtCoder の 4 コンテストを取り込み、42 行が task_id・contest_id・記号・title まで seed と一致した +- **staging への投入**(PR2.1): 2026-10-25 にメンテナが、管理画面から AtCoder の 4 コンテストを取り込み、2025 / 2026 夏合宿の CSV を投入した。10 コンテストの行は `ATCODER_JAG` 42 行・`AOJ_JAG` 72 行で、想定どおり + +#### PR2.1 をロールバックするとき + +取り込みの後に PR2.1 より前の版へ戻すと、旧版の Prisma Client は `ATCODER_JAG` を読めない。取り込みは既存の task_id を skip するので、戻した値はロールフォワードしても残る。 + +1. ロールバックの前に、該当行を `OTHERS` に変え、返った `expected_count` を環境名とともに控える(全問を取り込んでいれば 42)。復旧が終わるまで、4 コンテストの追加・削除・分類の変更はしない + +```sql +WITH changed AS ( + UPDATE "task" + SET "contest_type" = 'OTHERS' + WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') + AND "contest_type" = 'ATCODER_JAG' + RETURNING "id" +) +SELECT count(*) AS expected_count FROM changed; +``` + +2. ロールフォワードでは、PR2.1 以降をデプロイしてから、`expected_count` を控えた件数に置き換えて実行する。件数が違えば `RAISE` で UPDATE ごと取り消されるので、期待値を書き換えて通さず、記録と対象行を調べる。最後の SELECT が `ATCODER_JAG` の 1 行(件数は控えた値)だけを返せば復旧完了。控えた件数が 0 なら SELECT も 0 行になる。`OTHERS` など別の分類が残れば、復旧は終わっていない + +```sql +DO $$ +DECLARE + expected_count integer := 42; -- Replace with the recorded rollback count. + restored_count integer; +BEGIN + UPDATE "task" + SET "contest_type" = 'ATCODER_JAG' + WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') + AND "contest_type" = 'OTHERS'; + + GET DIAGNOSTICS restored_count = ROW_COUNT; + IF restored_count <> expected_count THEN + RAISE EXCEPTION 'restored % rows, expected %', restored_count, expected_count; + END IF; + RAISE NOTICE 'restored % rows, expected %', restored_count, expected_count; +END $$; + +-- Expected: ATCODER_JAG only, with the recorded rollback count. +SELECT "contest_type", count(*) FROM "task" +WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') +GROUP BY "contest_type"; +``` + +### テスト + +| 決定 | PR | 理由 | +| ------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| seed の検査は、補正した 8 コンテストを literal で、title を「問題名(`name`)と同じ」で見る | PR2 | 補正の正しさは基本ルールの関数では検査できない。title は「問題 ID が前に付いていない」だけだと、空や別の問題の title にすり替わっても通る(Codex の指摘) | +| AOJ の fixture は取り直さない | PR1.5 | 応答の形が同じため。取り直すと統計値(`solvedUser` など)や新しい回の追加で差分が出て、fixture に依存するテストの期待値まで変わる | +| AOJ のテストは nock のまま、クライアントを本番の定数から作る | PR1.5 | CI で実 API を叩かないために nock でモックするのはメンテナの意図どおりで、そのまま残す。問題は、テストが自前の旧ホストからクライアントを作っていて、定数を間違えても通ることだった | +| `nock.disableNetConnect()` を足す | PR1.5 | モックを書き忘れたリクエストが、黙って実 API に飛ぶのを防ぐ | +| 配列の contest_id を、seed の JAG 合宿系・模擬地区・AtCoder 上の JAG と突き合わせる | PR2.5 | 計画のテスト(件数・先頭と末尾・2014 の並び・括弧書き)だけでは、手書きの 76 件の id の誤り(そのテーブルが黙って表示されなくなる)を検出できない(メンテナの判断で追加) | + +分かったこと(`verify-test-strength` による変異テスト): + +- **PR1**: 15 個の変異をすべて検出した。seed 整合テストの `ICPC Prelim 2024 F is problem 1677` は単独で検出する変異が無いが、唯一の literal な期待値(対応表とデータが一緒にずれたときの歯止め)なので残した +- **PR1.5**: `AOJ_API_BASE_URL` の 4 つの変異(末尾の `/` を外す、旧ホストに戻す、`/api/v1/` にする、`/api` を外す)は、どれも 26 件すべてのテストで検出された +- **PR2**: 6 つの変異をすべて検出した(JAG の記号を PRELIM だけ / ICPC だけにする、補正した記号を基本ルールに戻す、基本ルールのコンテストで A・B を入れ替える、title に問題 ID を付け直す、模擬地区の記号を問題 ID に戻す)。title のテストは、空の title・別の問題の title・問題 ID の付け直しも検出する +- **PR2.1**: 9 つの変異をすべて検出した(seed の記号の入れ替え・行の欠落・title の不一致、map の欠落、優先度の入れ替え、`jag` / `jag2` の前方一致、4 つのキーでの前方一致、ラベルの定数化)。4 つのキーでの前方一致は当初見逃しており、Codex の指摘でテストを足した +- **PR2.5**: 11 個の変異をすべて検出した(AtCoder の 4 件を `AOJ_JAG` にする、分類の判定を反転する、括弧を半角にする、括弧書きを常に付ける、`abbreviationName` を定数にする、provider key から contest_id を外す、2014 の模擬地区を Day4 の後に動かす、配列の行の欠落・先頭の行の欠落・id の誤り・id の重複)。テストで並びを見ているのは先頭・末尾・2014 の 3 件だけなので、配列の並びと括弧書きは investigation.md の表と機械的に突き合わせ、76 件すべて一致した + +### 開発環境(dev container) + +| 決定 | PR | 理由 | +| ------------------------------------------------------ | ----- | --------------------------------------------------------------------------------------------------- | +| 許可リストの旧ホストは残さず、新しいホストに置き換える | PR1.5 | 旧ホストは 410 を返すだけで使い道が無い。許可先はデータを持ち出せる経路でもあるので、数を増やさない | + +分かったこと(ローカルの管理画面の取り込みが 500 になった原因。ブラウザにはどちらも「Failed to load resource: 500」とだけ出る): + +- **許可リスト**(PR1.5): 開発サーバーはコンテナの中で動き、外向き通信は許可リスト方式で、新しいホスト(163.143.87.177)が入っていなかった(旧ホストは 163.143.87.219)。サーバーの `fetch` が接続で拒否され、管理画面のアクションが `fail(500)` を返していた。ブラウザはホストの Mac から直接開くので見えていた。`init-firewall.sh` は Dockerfile の `COPY` でイメージに入り、起動時に 1 回だけ DNS で IP を引くので、編集を反映するにはコンテナの再起動ではなく再ビルドが要る。再ビルドの後、2026-10-24 にメンテナがローカルで AOJ から取得できることを確かめた +- **古い Prisma Client**(PR2.1): enum を足す前から動いている開発サーバーは、古い Prisma Client を持ったままで、`ATCODER_JAG` の登録が 500 になる。Vite はソースの変更を反映するが `node_modules` は読み込み直さないので、一覧には新しいコンテストが出る。開発サーバーの再起動で直る + +分かったこと(ローカルでテーブルが出ない): + +- **seed の流し直し**(PR2.5): PR2.1 より前に seed したローカル DB には 2025 / 2026 の夏合宿が無く、「JAG 模擬地区」にテーブルが出なかった。seed は既存の行を skip して足りない行だけ入れるので、`pnpm db:seed` を流し直せば入る + +### PR3 の実装中に分かったこと + +- **実装中の変更**: `task_index_label.test.ts` の既存ケースが約 80 件あるので、fixture の `taskId` は省略できる項目にし、テスト側で通常の ID を補った。必須にしたのは関数の引数だけ +- **確認の手段**: seed との突き合わせのテスト 2 件(ダミーは全件 map にある・map のキーは全件 seed にある)が、map の漏れと打ち間違いを守る。CSV の記号は投入後の SQL で確かめる From 0dc35a08c043fd552a4b05bc779d5bbd2de9d562 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 09:57:26 +0000 Subject: [PATCH 10/15] docs: update task title index plan and investigation Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 12 +- .../task-title-index-change-impact/plan.md | 489 +++++------------- 2 files changed, 146 insertions(+), 355 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index 2af94ea18..06015ae92 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -1,6 +1,6 @@ # 調査の根拠データと検討の経緯 -[plan.md](./plan.md) と PR ごとの計画(`plan-pr*.md`)の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** +[plan.md](./plan.md) の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** | 章 | 内容 | 主に使う PR | | ----------------------------------------------- | --------------------------------------------------- | --------------- | @@ -12,13 +12,13 @@ | [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | | [7](#7-pr3-欠けている問題) | 欠けている問題の調査、ダミーの設計、影響範囲 | PR3 | -PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [plan.md](./plan.md#完了分の詳細pr0pr3)。 +PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [plan.md](./plan.md#実装中に分かったこと)。 --- # 1. PR2 の照合(2026-10-24) -メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。目視の結果で、PR2 の照合スクリプト(書き写したスプレッドシートとの機械的な照合)でも同じ結果になった([plan.md](./plan.md#照合記号の対応表))。 +メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。目視の結果で、PR2 の照合スクリプト(書き写したスプレッドシートとの機械的な照合)でも同じ結果になった([plan.md](./plan.md#スプレッドシートとの照合))。 スプレッドシートで折りたたまれた行(40–50 ほか 19 か所)は JAG 模擬国内だけで、合宿系・模擬地区は含まない(メンテナに確認済み)。 @@ -68,7 +68,7 @@ AtCoder Problems の `contests.json` には、ほかに `JAG2013Spring` / `JAG20 ## 表示順(76 コンテスト) -PR2.5 の配列(`aoj_jag_regional_entries.ts`)の元。スプレッドシートの行順そのまま(上が新しい)。括弧書きは M16 に従う(「過去問」「in AtCoder」「AMATSUKAZE2025」は書かない)。「DB」列の「無し」は PR2.1 で入るもの、「補正」は上の 8 コンテスト。 +PR2.5 の配列(`aoj_jag_regional_entries.ts`)の元。スプレッドシートの行順そのまま(上が新しい)。括弧書きは plan.md の決定事項に従う(「過去問」「in AtCoder」「AMATSUKAZE2025」は書かない)。「DB」列の「無し」は PR2.1 で入るもの、「補正」は上の 8 コンテスト。 | # | 行 | contest_id | 括弧書き | DB | | --- | ---- | ------------------------------------ | ----------------------- | ---- | @@ -167,7 +167,7 @@ contest_id から並びを導けない例(スプレッドシートの上から | 冬コンテスト | 冬合宿 | `JAGWinter2009` / `JAGWinter2011`(日の無いもの) | | 模擬アジア地区 | 模擬地区 | `JAGRegional*` | -テーブル名は現行のコードに合わせる(M16)。出典欄と同じ名称になり、違うのは括弧書きの有無だけになる。 +テーブル名は現行のコードに合わせる。出典欄と同じ名称になり、違うのは括弧書きの有無だけになる。 --- @@ -376,7 +376,7 @@ PR2 後: index='A', title='2389. Asynchronous Exceptions' 「管理画面から SvelteKit の action で更新する」案には実質的な利点がある。 - **再実行できる。** マイグレーションは適用後 immutable なので、欠落判断の誤りが後から見つかると新しいマイグレーションを積むことになる -- 欠落判断は人間が行う(M6)以上、**修正は起こる前提**であるべき +- 欠落判断は人間が行う以上、**修正は起こる前提**であるべき 一方で、この案は**問題記号の入力元が定義できないと成立しない**。手入力なら 1,000 回超の手作業に戻り、難所 1 で否定したものに戻る。コード上の対応表を読むなら、実質「対応表を DB に同期するボタン」になる。 diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index 2e16898f0..ed0293c61 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -1,12 +1,6 @@ # AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する -**日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装済み、未マージ。残りは [残りのタスク](#残りのタスク) - -このファイルに、全体像、PR をまたぐ決定・手順、PR3 の設計、完了分の決定、残りのタスクをまとめた。根拠データは investigation.md にある。 - -| ファイル | 内容 | -| -------------------------------------- | -------------------------------------------------- | -| [investigation.md](./investigation.md) | 根拠データ。PR2 の照合結果、表示順、欠落の一覧など | +**日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装済み、未マージ。根拠データは [investigation.md](./investigation.md)。 ## ゴール @@ -19,68 +13,46 @@ | **G1** 出典欄 | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | | **G2** コンテストテーブル | 問題 ID 3467 → ソート位置から `F` を算出 | `task_table_index` の `F` を**そのまま使う** | -## 現在の状態 - -`task_table_index` は、AOJ ICPC・JAG の全範囲で問題記号になった。表示側の変換は PR1 で削除し、コンテストテーブルのセルは `task_table_index` から `A. タイトル` を組み立てる。出典欄の書式(G1)は AOJ 全体に入っている。 - -| 範囲 | `task_table_index` | `title` | 管理画面の取り込み | コンテストテーブル | -| ----------------------------------------------------------- | ------------------ | ------- | ------------------------------------------ | -------------------------------- | -| ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632) | 記号 | 素 | 記号を付ける | あり | -| JAG 夏 / 冬 / 春合宿・模擬地区・in AtCoder(76 コンテスト) | 記号 | 素 | 合宿系は経路が無い。模擬地区は記号を付ける | 「JAG 模擬地区」にコンテストごと | - -経緯(詳細は[完了分の詳細](#完了分の詳細pr0pr3)): +## 現在の状態と PR -- PR1.5(#4219): AOJ の API は 2026-09-07 に別のホストへ移り、旧ホストは 410 を返す。追従した -- PR2(#4220): 合宿系と模擬地区を記号にし、JAG は全 round で取り込みでも記号を付けるようにした -- PR2.1(#4222): `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテストと 2025 / 2026 の夏合宿を seed に足した -- PR2.5: グループ「JAG 模擬地区」に 76 コンテストのテーブルを作った -- PR3: AOJ のコンテスト一覧に無い 26 問を足す。AOJ に無い 21 問は `NoJudge-` のダミーにした +AOJ ICPC・JAG の全範囲(ICPC 国内予選 / アジア地区 / JAG 模擬国内 79 コンテスト、合宿系・模擬地区・in AtCoder 76 コンテスト)で `task_table_index` が問題記号になった。title は素(問題 ID も記号も付かない)で、セルは `task_table_index` から `A. タイトル` を組み立てる。 -## PR の分割と順序 +| PR | 内容 | 状態 | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| PR0 | 出典欄の AOJ の書式(#4212) | 完了 | +| PR1 | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を記号にする。表示側の変換を削除し、取り込みは基本ルールで記号を付ける(#4217) | 完了 | +| PR1.5 | AOJ API の移転(2026-09-07、旧ホストは 410)に追従する(#4219) | 完了 | +| PR2 | 合宿系 + 模擬地区(66 / 625)を記号にし、合宿系の title から問題 ID を外す(#4220) | 完了 | +| PR2.1 | `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテストと 2025 / 2026 の夏合宿(計 10 コンテスト)を足す(#4222) | 完了(本番のデータも投入済み) | +| PR2.5 | グループ「JAG 模擬地区」に 76 コンテストのテーブルを作る | 完了 | +| PR3 | AOJ のコンテスト一覧に無い 26 問を足す(AOJ に無い 21 問はダミー) | 実装済み、未マージ。CSV の投入待ち | -| | 範囲 | コンテスト | 問題数 | 内容 | -| ---------------------------- | ------------------------------------------------------- | ---------- | ------------------------- | ------------------------------------------------------------------------------------ | -| [PR1.5](#完了分の詳細pr0pr3) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。完了(#4219) | -| [PR2](#完了分の詳細pr0pr3) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする。staging に反映(#4220) | -| [PR2.1](#完了分の詳細pr0pr3) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む)。staging に反映(#4222) | -| [PR2.5](#完了分の詳細pr0pr3) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」に、コンテスト別のテーブルを作る。実装済み、未マージ | -| [PR3](#pr3-欠落問題の補完) | 欠落問題の補完 | 10 | 26 | AOJ のコンテスト一覧に無い問題を足す(AOJ に無い 21 問はダミー)。実装済み、未マージ | +順序の制約: -PR1 と PR2 で、記号にするのは 1,257 問 / 145 コンテストになる。PR2 で完結し、PR3 に進まなくても機能として成立する。 - -- **PR1.5 → staging で JAG REGIONAL を取り込む → PR2** の順で進めた(済)。PR2 の件数チェックを staging で通すには、staging の模擬地区を本番と同じ 16 / 158 にそろえる必要があったため - **PR2 → PR2.5 は守る**。Provider を先に出すと、合宿系の数値の index と `'2389. …'` の title がそのまま表に出る -- **PR2.1 は PR2 と前後してよい**。PR2 の移行 SQL は index が問題 ID のままの行しか書き換えない -- **PR2.5 のコードは PR2.1 の後に出す**。AtCoder の 4 コンテストの Provider に `ATCODER_JAG` を使い、seed との整合テストも PR2.1 の seed を前提にする(計画では PR2.1 と独立させていたが、実装時に変えた。[理由](#分類と表示))。本番の PR2.1 のデータ投入は PR2.5 の後でもよいが、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(`TaskTable.svelte` は、データの無い Provider でも見出しと枠を描画する。[残タスク](#pr-外の残タスク)) -- **PR3 は独立**。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない +- **PR2.5 は PR2.1 の後**。AtCoder の 4 コンテストの Provider が `ATCODER_JAG` と PR2.1 の seed を前提にする。本番の PR2.1 のデータ投入が PR2.5 より後だと、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る +- **PR3 は独立**。PR2 で記号を補正済みなので、欠落を後から足しても既存の記号はずれない ## 残りのタスク -### PR3(実装済み、未マージ) - -ユニットテスト・lint・check は通過し、Codex のクロスレビューも済んだ(指摘はテストの強化 1 件で、反映済み)。 +PR3: 1. 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と `(No Judge)` を目で確かめる -2. PR を開き、staging へマージする -3. コードのデプロイの後に、メンテナが staging へ CSV(26 行)を投入し、[SQL](#staging--本番への投入メンテナ) で記号を確かめる +2. PR を開き、staging へマージする(Codex のクロスレビューは済み。指摘はテストの強化 1 件で反映済み) +3. コードのデプロイの後に、メンテナが staging へ CSV を投入し、[SQL](#staging--本番への投入メンテナ) で確かめる 4. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる -### PR 外の残タスク +PR 外(優先度の低いものを含む): -PR2 以降の計画には含めていない。 - -- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う(Codex / CodeRabbit)。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る。`getContests` 側もそろえる -- seed 整合テストの期待値を `assignProblemLetters` で作っている(PR1 と PR2.1 の Codex)。testing.md の「期待値は literal」に合わない。PR2 では補正したコンテストだけを literal にする(M18)。全件を literal にするかは未判断 -- `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する(PR2.5 の Codex)。`prepareContestTable` が `null` を返して消えるのは行だけ。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので PR2.5 では直さなかった(メンテナの判断) -- テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める。PR2.5 の配列(並びと括弧書き)が移行元になる([名称の違い](./investigation.md#名称の違い)) -- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する(Codex)。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める -- 本番の title の前後の空白 46 件は、実害が無いので直さない([下記](#スコープ外)) +- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る(`getContests` 側もそろえる) +- seed 整合テストの期待値を `assignProblemLetters` http://127.0.0.1:5173/で作っていて、testing.md の「期待値は literal」に合わない。補正したコンテストだけ literal にしている。全件を literal にするかは未判断 +- `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので触っていない +- テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める([名称の違い](./investigation.md#名称の違い)) +- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める ### スコープ外 -次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。PR1 から残っているタスクは[PR 外の残タスク](#pr-外の残タスク)。 - -本番の title のうち 46 件は前後に空白がある(AOJ 4 件、AtCoder 42 件。[詳細](./investigation.md#前後に空白のある-title))。実害が無く再発もしないので、本件では直さず残タスクとする。直すときは、1 本の UPDATE(`btrim`、46 件の件数チェック付き)で済む。 +次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。 | 範囲 | 本番(コンテスト / 問題) | 今の出典欄 | 残す理由 | | ----------- | ------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | @@ -88,179 +60,89 @@ PR2 以降の計画には含めていない。 | AOJ PCK | 1 / 12 | 数値の問題 ID のまま | 優先度が低い | | AOJ 大学系 | 27 / 30 | `UTPC 2010 - 2207(AOJ)` | 優先度が低い。本方式をそのまま当てられる | -大学系に広げるときは、`AOJ-KUPC2011` の 2279 を移行 SQL の対象から外す(index は既に `I` で正しく、基本ルールでは `A` になる)。 +- 大学系に広げるときは、`AOJ-KUPC2011` の 2279 を移行 SQL の対象から外す(index は既に `I` で正しく、基本ルールでは `A` になる) +- 本番の title のうち 46 件は前後に空白がある(AOJ 4 件、AtCoder 42 件。[詳細](./investigation.md#前後に空白のある-title))。実害が無く、取り込みが trim するので再発もしない。直すなら 1 本の UPDATE(`btrim`、46 件の件数チェック付き)で済む ## 3 つの難所 -**難所 1: 問題記号は AOJ API から取得できない。** API は「その問題がコンテストで何番目か」を返さない。ただし**基本ルール**(コンテスト内で問題 ID が若い順に `A`, `B`, `C`…)で復元できる。⇒ 対応表はスクリプトで機械生成できる。 - -**難所 2: 欠落があると基本ルールが破れる。しかも欠落はデータから検出できない。** ここでの欠落は、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いことを指す。AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのに一覧から外れている場合もある(ICPC 国内予選 2002 の E = AOJ 1123)。⇒ **コンテスト単位で外部情報(メンテナのスプレッドシート)と照合するしかない**。PR2 の範囲では、実際に 8 コンテストで先頭・途中の欠落が見つかった([照合結果](./investigation.md#記号の補正が要るコンテスト))。 - -**難所 3: 表示層で逃げられない。** 出典欄は `task_table_index` を直接読む。⇒ DB の値そのものを直す以外に手段がない。 +1. **問題記号は AOJ API から取得できない。** ただし**基本ルール**(コンテスト内で問題 ID が若い順に `A`, `B`, `C`…)で復元できる。⇒ 対応表はスクリプトで機械生成できる +2. **欠落があると基本ルールが破れる。しかも欠落はデータから検出できない。** 欠落とは、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いこと。AOJ に登録が無ければ ID も無く、残った問題の ID は連番のままになる。⇒ **コンテスト単位で外部情報(メンテナのスプレッドシート)と照合するしかない**(PR2 では 8 コンテストで欠落が見つかった。[照合結果](./investigation.md#記号の補正が要るコンテスト)) +3. **表示層で逃げられない。** 出典欄は `task_table_index` を直接読む。⇒ DB の値そのものを直す以外に手段がない ## 決定事項 -番号は PR0 からの通し番号。PR0 / PR1 で完了した M4 / M7 / M10 / M11 は[完了分の詳細](#完了分の詳細pr0pr3)。 - -| # | PR | 決定 | 理由 | -| --- | ----- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| M1 | 全体 | DB の値そのものを A/B/C に直す | 難所 3 | -| M2 | 全体 | 既存データは移行 UPDATE で直す | seed も取り込みも「既存なら skip」で、既存レコードは自動更新されない | -| M3 | PR2 | JAG 合宿系 467 問も対象に含める | seed にデータがあり、Provider だけが無い | -| M5 | 全体 | 管理画面からの取り込みでは、基本ルールで記号を付ける | 放置すると取り込むたびに数値の index が再混入する。毎年の新しいコンテストも追記なしで記号になる | -| M6 | 全体 | 対応表は基本ルールで生成し、欠落は人間がスプレッドシートと照合する | 難所 2 | -| M8 | 全体 | 移行は Prisma のデータマイグレーションで適用する | CI の既存の仕組みに乗る | -| M9 | PR2 | 合宿系の `title` から問題 ID のプレフィックスを除く | index だけ記号にすると `removeTaskIndexFromTitle` が空振りし、`'2389. …'` が表に出る | -| M12 | PR2 | JAG 模擬地区(本番のみ)を PR2 に含め、seed にも本番から抽出して追加する | 取り込みとの整合を seed で検査でき、ローカルで移行 SQL を確かめられる。記号がずれる 2005 / 2013 も seed に入るので、ローカルで取り込んでも skip される | -| M13 | PR2 | 基本ルールで記号がずれるコンテストは、移行 SQL でコンテストの全行を上書きし、各 UPDATE の件数を DO ブロックで検査する | UPDATE は 0 件でもエラーにならないため、想定外の件数なら RAISE してマイグレーション全体を戻す | -| M14 | PR2 | 管理画面からの取り込みでは、JAG は全 round(模擬国内 / 模擬地区)で記号を付ける | M5 の拡張。2005 / 2013 の補正は取り込み側に持たない(既存行は skip される) | -| M15 | PR2.5 | 合宿系・模擬地区・in AtCoder を 1 グループ「JAG 模擬地区」にまとめ、テーブルはコンテストごとにする | 並びはスプレッドシートの行順(新しい順)。contest_id から導けないので明示的な配列で持つ | -| M16 | PR2.5 | テーブル名は `getContestNameLabel` の名称に括弧書き(`JAG` / `有志` / `in JAG 夏合宿 20xx Day3`)を付ける。出典欄は変えない | 名称は現行の `JAG_TRANSLATIONS` に合わせる。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない。出典欄とそろえるのは #4018(コンテスト情報の DB 化)で行う | -| M17 | PR2.1 | AtCoder 上の 4 つは `ATCODER_JAG` を新設し、AOJ の 2025 / 2026 は seed と Supabase の CSV で入れる | `ATCODER_JAG` は enum・優先度・分類ルールのいずれも `UNIVERSITY` の直前に置き、4 つを完全一致で分類する。CSV は移行の後に記号付きで入れる | -| M18 | PR2 | seed の記号の検査では、補正したコンテストだけ期待値を literal で書く | 補正していないコンテストは `assignProblemLetters` と一致することを見る | - -OPTIONAL は 1 件のみ。**O5**: `createTask` の check-then-act を upsert 化 — 本件と独立の既存課題で別ブランチ推奨([詳細](./investigation.md#check-then-act-の競合))。 - -### 却下した方針(全体) - -PR ごとの却下は各ファイルに書いた。 - -| 案 | 却下理由 | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| 対応表を `prisma/tasks.ts` に手書きし、seed だけ直す | 取り込みが数値を入れ続け、**新旧の書式が混在**する | -| 問題 ID の連番性から欠落を機械判定する | **論理的に成立しない**(難所 2) | -| seed を冪等化して production に流す | **不可**。`addUsers` / `addAtCoderAccounts` / `addWorkBooks` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts`) | -| `title` に記号を入れて `'A. Peculiar Protocol'` に揃える | 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) | -| 管理画面に同期 action を足す | **保留**。再実行できる利点はあるが、問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) | - -## データ移行の共通手順 - -PR2(記号の移行)と PR2.1(enum の追加)、PR3 に当てはまる。 - -### CI での適用 - -`.github/workflows/ci.yml` の流れ(2026-10-24 に確認): - -1. `main` / `staging` への push と、`pull_request` で起動する -2. build / lint / check / unit-test が並列に走る -3. 4 つとも通ると、`main` 以外では `preview` ジョブ、`main` では `production` ジョブが走る。どちらも `vercel build` の**後**・Vercel デプロイの**前**に `prisma migrate deploy` を流す(build が失敗すれば DB に触れない) - -| 起動したもの | ジョブ | 適用先 | -| ----------------------------------------------------------- | ------------ | --------------------------------------- | -| PR の CI(ref は `refs/pull/N/merge`)、`staging` への push | `preview` | `PREVIEW_DATABASE_URL`(staging の DB) | -| `main` への push | `production` | `PRODUCTION_DATABASE_URL` | - -- **PR の CI でも staging の DB に適用される**。PR を開いた瞬間ではなく、その PR の CI で 4 ジョブが通った後だが、**staging へのマージより前**である。`preview` ジョブの条件は `github.ref != 'refs/heads/main'` だけで、PR の ref も当たる。実際、#4217 の `pull_request` の実行でも `preview` ジョブが成功している(ログはログインしないと見られないので、`migrate deploy` の出力までは確かめていない)。staging へマージしたときにも走るが、適用済みなので何もしない -- このため SQL の確認は PR を開く前にローカルで済ませる。PR に push し直すたびに CI が走るが、適用済みのマイグレーションは編集しても再適用されない -- `main` へ昇格した時点で production に適用されるため、**staging での確認が自然にゲートになる** -- 本件の範囲外だが、`preview` ジョブの「Assign staging domain」の手順は、コメントに「if staging branch」とあるのに条件は `github.ref != 'refs/heads/main'` である。PR のデプロイにも staging のドメインが付け替わる - -### 手順 - -1. `pnpm exec prisma migrate dev --create-only --name ` -2. SQL を `migration.sql` に書く -3. **ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する** -4. ローカルで `pnpm exec prisma migrate dev` を実行して適用し、表示を確認する -5. PR を開く → staging へマージして確認 → `main` へ昇格 - -手順 3–4 は**必ず PR を開く前に**済ませる。PR を開いた時点で preview DB に適用されうるうえ、適用済みのマイグレーションは編集しても再適用されない。PR を開いた後に SQL の誤りが見つかったら、新しいマイグレーションを積む。 - -手順 3 により **SQL の正しさを構文から判断する必要がなくなる**。全件を見る必要はないが、**コンテストの切り替わり目(記号が `A` に戻る箇所)**と**問題数の少ない古いコンテスト**は目視する価値がある。 - -**staging が防げないもの**: **対応表・欠落判断そのものの誤り**。誤った記号でも SQL は正常に完走し、staging では「それらしい表示」になる(難所 2)。 - -### 事故対策 - -| 観点 | 内容 | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **爆発半径** | 一括で復元できるのは AOJ ICPC / JAG だけ(`task_table_index == task_id` が成り立っていたため)。WHERE 句が壊れて AtCoder 系に波及すると**一括復元の手段が無くなる**([実測](./investigation.md#復元可能性の実測)) | -| **復元** | 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる | -| **バックアップ** | Supabase Pro のため PITR が利用可。復元トリックが効くのは一度目だけなので、**再修正時は PITR が唯一の手段** | -| **`updated_at`** | `@updatedAt` は Prisma Client 側の実装で DB トリガーではない。生 SQL では更新されない。**意図的に含めない** | -| **キャッシュ** | TTL 1 時間(`src/lib/clients/cache.ts`)。生 SQL と CSV の投入は `invalidateTaskCaches()` を経由しない。「更新したのに表示が変わらない」ときはこれを疑う | -| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される([移行 SQL](#移行-sql)) | -| **デプロイ直前の数分** | マイグレーションの適用から Vercel のデプロイ完了までの間は、旧コードが新しいデータを描画する。数分で解消するので**許容する** | - -### スプレッドシートとの照合 - -対応表は次の手順で作る(M6)。PR1 のスクリプトを直して使う(仕組みは [照合](#照合記号の対応表))。 - -1. 基本ルールで対象範囲の記号を生成する -2. コンテスト単位で、メンテナのスプレッドシートと照合する ← 人手が要るのはここだけ(書き写し) -3. 先頭・途中に欠落があるコンテストは、スプレッドシートの位置から記号を決める(末尾の欠落は記号がずれないので補正不要) -4. 欠落した問題が AOJ に問題番号で存在するかを確かめ、PR3 に引き継ぐ - -> **注意**: 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても表示は成立してしまう(前詰めでずれた記号が出るだけ)。 - -**スクリプトには総数のアサーションを入れる。** 抽出漏れが欠落の誤検出に直結する(調査中に実際に踏んだ。[事例](./investigation.md#6-調査中の誤りと訂正自戒))。抽出件数が対象範囲の既知の値と一致しなければエラーで止め、欠落として扱わない。 +| 決定 | 理由 | +| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| DB の値そのものを A/B/C に直す。既存データは移行 UPDATE で直す | 難所 3。seed も取り込みも「既存なら skip」で、既存レコードは自動更新されない | +| 対応表は基本ルールで生成し、欠落は人間がスプレッドシートと照合する | 難所 2。下の[照合の手順](#スプレッドシートとの照合) | +| 管理画面の取り込みは、対応表ではなく基本ルールで記号を付ける。JAG は全 round(模擬国内 / 模擬地区)で付ける | 対応表を取り込み側に置くと、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。補正した 8 コンテストは既存行なので、取り込みは skip し補正は持たない | +| 移行は Prisma のデータマイグレーションで適用する | CI の既存の仕組みに乗る | +| 合宿系の `title` から問題 ID のプレフィックスを除く | index だけ記号にすると `removeTaskIndexFromTitle` が空振りし、`'2389. …'` が表に出る | +| 合宿系は管理画面の取り込みに足さず、seed と Supabase の CSV で入れる | 既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加には重い(メンテナの判断) | +| 表示側の変換と `getTaskLabels` を削除する | DB が記号を持てば変換は恒等になる。`getTaskLabels` だけを残すと、上書きする Provider が 0 の使われないコードが残る | +| ヘッダーの並びは `localeCompare` | 記号に `Number()` を当てると NaN になる | +| `buildTitleWithTaskIndex` は先頭の記号を外してから付ける。`removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す | AtCoder の title は `A. …` を含むので、付けるだけだと二重になる。本文の `. ` まで消すと `Dr. Podboq` → `DrPodboq` になる | +| AOJ ラベルから全角括弧を外す | 出典欄の目標書式ではコンテスト名を括弧で囲まない。検索・投票・タグ・取り込み画面などの表示も変わるが許容した | +| AtCoder 上の JAG 4 つは `ATCODER_JAG` を新設し、contest_id の完全一致で分類する | `jag` の前方一致だと `JAG2013Spring` / `JAG2014Spring`(AOJ の `JAGSpring2013` / `2014` と同じコンテスト)まで入り、二重になる。`AOJ_JAG` だと `(AOJ)` や `getTaskUrl` が AOJ 前提で動く | +| 合宿系・模擬地区・in AtCoder を 1 グループ「JAG 模擬地区」にし、テーブルはコンテストごと。並びは明示的な配列で持つ | 同じ年の中の前後関係(模擬地区 2014 は夏合宿 2014 の Day3)を表せるのは 1 グループだけ。スプレッドシートの行順は contest_id から導けない。配列の書き漏れは seed との整合テストで落ちる | +| テーブル名は `getContestNameLabel` の名称に括弧書き(`JAG` / `有志` / `in JAG 夏合宿 20xx Day3`)を付け、出典欄は変えない | スプレッドシートの名称にすると出典欄と食い違う。そろえるなら検索・投票・タグ・fixture に波及するので #4018 に回した。ラベル(出典欄)に(有志)は付けない | +| Provider の `contestType` は、AtCoder 上の 4 コンテストだけ `ATCODER_JAG`、ほかは `AOJ_JAG`。グループのキーは `aojJagRegional` | `contestType` が効くのは provider key と `getTaskTableHeaderName` だけで、表示は変わらない。キーは localStorage に保存されるので後から変えられない | +| seed の記号の検査は、補正した 8 コンテストだけ literal で書く。title は「問題名(`name`)と同じ」で見る | 補正の正しさは基本ルールの関数では検査できない。title は「問題 ID が無い」だけだと、空や別の問題にすり替わっても通る | + +却下した方針: + +- 対応表を `prisma/tasks.ts` に手書きし seed だけ直す: 取り込みが数値を入れ続け、新旧の書式が混在する +- 問題 ID の連番性から欠落を機械判定する: 論理的に成立しない(難所 2) +- seed を冪等化して production に流す: `addUsers` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts`) +- `title` に記号を入れて `'A. Peculiar Protocol'` に揃える: 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) +- 管理画面に同期 action を足す: 保留。問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) ## PR3: 欠落問題の補完 -根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。採らなかった案は、すべてこの節にまとめた。 +根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。 -### 概要 - -AOJ の JAG / ICPC のコンテストで、公式の問題なのに seed に無い 26 問を足す。PR2 で今ある問題の記号を補正済みなので、足しても今ある記号は変わらない。 +AOJ の JAG / ICPC で、公式の問題なのに seed に無い 26 問を足す。管理画面の取り込みは AOJ のコンテスト一覧を使うので取り込めない。seed に足し、staging / 本番はメンテナが Supabase の CSV で入れる。 | 種類 | 問題 | 入れ方 | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | AOJ にあるが、取り込み元のコンテスト一覧に無い(5 問) | 1123(ICPC 国内予選 2002 の E)、3643(JAG 夏合宿 2024 Day3 の G)、2565〜2567(JAG 模擬地区 2013 の A〜C) | AOJ の問題 ID のまま、通常の問題として入れる | | AOJ に無い(21 問) | 2005〜2008 年の JAG の 7 コンテスト([一覧](./investigation.md#aoj-に無い-21-問)) | ダミーの Task として入れ、リンクは jag-icpc.org に向ける | -管理画面の取り込みは AOJ のコンテスト一覧を使うので、どちらも取り込めない。seed に足し、staging / 本番にはメンテナが Supabase の CSV で入れる。 - -### 決めたこと +3643 は 2026-10-10 に AOJ に追加されたばかりで公開前かもしれない(`available` が 4、提出が 0)が、入れる。合宿系は取り込みの対象外なので、一覧に入るのを待っても取り込めない。 -#### AOJ にある 5 問 +### ダミー(21 問)の決定 -- AOJ の問題 ID と、公式の記号で入れる -- 3643 は 2026-10-10 に AOJ に追加されたばかりで、公開前かもしれない(`available` が 4、提出が 0)が、今回入れる - -#### AOJ に無い 21 問(ダミー) - -- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま +- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字のまま。接頭辞が「採点の仕組みが無い」という分岐の理由をそのまま表し、AOJ(数字)とも AtCoder(`abc300_a`)とも構造上ぶつからない。判定は `isNoJudgeTask()`(`startsWith('NoJudge-')`)に一本化する - **title**: jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記。`Wireing Assistant` も原文どおり -- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。自分で採点するのに要る問題文・ジャッジデータ・講評のどれにも下りられる -- **リンクの出し方**: `getTaskUrl()` の URL ジェネレータに `NoJudgeGenerator` を足し、`AojGenerator` より前に置く。URL は `task_id` をキーにした map から引く -- **出典のラベル**: `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`) -- **見た目**: アイコンもツールチップも入れない。採点が無いことは出典のラベルで伝える -- **出す場所**: コンテスト別の表・問題一覧・投票・タグ・問題集のすべて。Task のレコードがあれば動く -- **AOJ に後から追加されたら**: 管理画面から取り込まず、DB の `task_id` を AOJ の ID に UPDATE する([手順](./investigation.md#aoj-に後から追加されたとき)) - -変更が要る機能は、問題へのリンクと出典のラベルの 2 つ。影響範囲の全体は [investigation.md](./investigation.md#影響範囲)。 +- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。問題文・ジャッジデータ・講評のどれにも下りられる。URL は組み立てず、ブラウザで開けたものをパーセントエンコードのまま貼る +- **リンクの出し方**: `NoJudgeGenerator` を `AojGenerator` の前に置く(ダミーは JAG の `contest_id` を使うので、順序に意味がある)。`task_id` をキーにした map から引き、漏れていれば空文字を返して AOJ には回さない。seed との突き合わせのテスト 2 件が、map の漏れと打ち間違いを守る +- **出典のラベル**: `(AOJ)` ではなく `(No Judge)`(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。`addContestNameToTaskIndex` に必須の `taskId` を足した +- **見た目**: アイコンもツールチップも入れない。表のセルに幅が無く、スマホにはホバーが無い。引っかかっても、新しいタブで開けば提出フォームの無い Wiki だと分かる +- **記号の検査**: ダミーのある 7 コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れる(`Number()` で並べられないため、入れ忘れを検出するテストがある)。連番に戻った `JAGRegional2013` と `JAGSummer2024-day3` は外した +- **AOJ に後から追加されたら**: 管理画面から取り込まず、DB の `task_id` を AOJ の ID に UPDATE する。取り込みは後から足された問題へずれた記号を付け、重なると表から黙って消える。外部キー 6 つは `ON UPDATE CASCADE`([手順](./investigation.md#aoj-に後から追加されたとき)) ### 採らなかった案 -| 対象 | 採らなかった案 | 理由 | -| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 3643 の入れ方 | AOJ のコンテスト一覧に入るのを待ち、管理画面で取り込む | 合宿系は管理画面の取り込みの対象外なので、一覧に入っても取り込めない | -| ダミーの title | 一覧でしか確かめていない 19 問を、問題文の PDF の表紙でも確かめる | 一覧はスプレッドシートとも一致していて、誤記が残る見込みは小さい | -| ダミーの `task_id` | 数字(100000 番台など) | 既存のコードは壊れないが、AOJ の本物の ID と見分けられない。数字の範囲は AOJ が決める領域(通常の問題は 40xx 番台まで、AOJ 自身のダミーに 9900〜9995)で、衝突の心配が残る | -| ダミーの `task_id` | 50xx 番台 | AOJ の通常の採番がいずれ届く | -| ダミーの `task_id` | `Missing-` | 「欠けている」は AOJ にある 5 問にも当てはまり、意味がずれる。問題文はあるのに、中身の無い仮のレコードやエラーページにも読める | -| ダミーの `task_id` | `jag-icpc_` など出所を表す接頭辞 | 分岐の理由(採点が無い)ではなく、問題文の置き場所を表す | -| リンク先 | PDF への直リンク | 問題文までは 1 クリックだが、講評に辿れない。添付ファイル名が揃っておらず、切れる要因が増える | -| リンク先 | `問題文とデータセット` のページ | 講評に辿れない | -| リンクの出し方 | `getTaskUrl()` の先頭で map を引く | コードは 2〜3 行で済むが、`getTaskUrl()` に特別扱いが入る。判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が割れる。map に漏れがあると AOJ のジェネレータに回り、開けない AOJ のリンクになる | -| リンクの出し方 | Task に `url` 列を足す | マイグレーションが要り、`getTaskUrl()` の呼び出し元 9 ファイルに URL を渡す変更が波及する。21 件には重い。他サイトに対応するときに再検討する | -| リンクの出し方 | URL を年・大会区分・ファイル名から組み立てる | 規則の推測が外れると、黙って間違ったリンクになる。ブラウザで開けた URL をそのまま貼る | -| リンクの出し方 | map を隠し、URL を取り出す関数だけを export する | seed との突き合わせのテストで、map のキーを列挙できない | -| リンクの出し方 | 外部リンクを CI で定期的に確かめる | 外部サイトの状態でテストが落ちるようになる | -| 判定の置き場所 | `isNoJudgeTask()` を `src/lib/utils/` に置く | `src/lib/utils/task.ts` が `#lib/contests/index.js` を import しているので、`lib/contests` の `task_index_label.ts` から使うと循環 import になる | -| 出典のラベル | `taskId` を省略できる引数にする | 渡し忘れた呼び出し元が、黙って `(AOJ)` のままになる。必須なら、型チェックが漏れを見つける | -| 見た目 | セルにアイコンを足す | セルの幅が足りない(AOJ の表は 1 行に最大 6 列で、問題名はもう省略されている) | -| 見た目 | リンクに `title` 属性のツールチップ | 幅は使わないが、スマホでは出ない。知らせる仕組みが必要になったら、最初に検討する | -| 見た目 | 表のタイトルにコンテスト単位で書く(`note`) | `note` は主催者(JAG / 有志)の欄なので意味が混ざり、どの問題が対象かも分からない | -| 見た目 | 問題の詳細ページに一文添える | 表から直接開く人の目には入らない | - -#### staging / 本番への投入(メンテナ) +- **`task_id`**: 数字(100000 番台は AOJ の本物の ID と見分けられず、AOJ が決める領域で衝突が残る。50xx 番台は AOJ の採番がいずれ届く)、`Missing-`(AOJ にある 5 問にも当てはまり、仮のレコードにも読める)、`jag-icpc_` など(置き場所を表し、分岐の理由を表さない) +- **リンク先**: PDF への直リンク(講評に辿れず、添付ファイル名が揃っておらず切れやすい)、`問題文とデータセット` のページ(講評に辿れない) +- **リンクの出し方**: + - `getTaskUrl()` の先頭で map を引く: 判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が割れる。漏れると開けない AOJ のリンクになる + - Task に `url` 列を足す: マイグレーションと呼び出し元 9 ファイルの変更が要り、21 件には重い。他サイトに対応するときに再検討する + - URL を年・大会区分・ファイル名から組み立てる: 規則の推測が外れると黙って間違ったリンクになる + - map を隠して取り出す関数だけを export する: seed との突き合わせで map のキーを列挙できない + - 外部リンクを CI で確かめる: 外部サイトの状態でテストが落ちる +- **判定の置き場所**: `isNoJudgeTask()` を `src/lib/utils/` に置く案は、`lib/utils/task.ts` が `#lib/contests/index.js` を import しているので循環 import になる。`lib/contests` に置いた +- **`taskId` を省略できる引数にする**: 渡し忘れが黙って `(AOJ)` のままになる。必須なら型チェックが漏れを見つける +- **見た目**: セルにアイコン(AOJ の表は 1 行に最大 6 列で幅が足りない)、`title` 属性のツールチップ(スマホでは出ない。知らせる仕組みが必要になったら最初に検討する)、表のタイトルに `note` で書く(主催者の欄と意味が混ざる)、詳細ページに一文添える(表から直接開く人の目に入らない) +- **title の確認**: 一覧でしか確かめていない 19 問を PDF の表紙でも確かめる案は、一覧がスプレッドシートと一致しているので見送った + +### staging / 本番への投入(メンテナ) コードのデプロイの後に CSV を入れる(先に入れると、デプロイまでの間、ダミーが開けない AOJ のリンクと `(AOJ)` のラベルで出る)。 - 列は `id`・`contest_type`・`created_at`・`updated_at` も明示する。Supabase の CSV は Prisma の既定値を使わず、`created_at` は DB に既定値があっても空になった例がある(`docs/guides/agent-rules/prisma-db.md`) -- `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする +- `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする(`Task.id` を再計算して引く箇所は無い。[根拠](./investigation.md#id-の採番規則が経路ごとに違う)) - CSV の投入は `invalidateTaskCaches()` を経ないので、画面に出るのはタスクのキャッシュの有効期限(1 時間)が切れた後 -- 行の一覧は下の表(seed の 26 行と同じ) | `task_id` | `contest_id` | `contest_type` | `task_table_index` | `title` | | ---------------------------- | ------------------ | -------------- | ------------------ | ---------------------------- | @@ -322,177 +204,86 @@ ORDER BY contest_id; | JAGWinter2006-day3 | 10 | ABCDEFGHIJ | | JAGWinter2007-day3 | 10 | ABCDEFGHIJ | -## 完了分の詳細(PR0〜PR3) - -コードと git の履歴から読み取れない判断と事実だけを、項目ごとにまとめる。変更したファイルや関数は各 PR の差分を見る。 - -### 進め方 - -| 決定 | PR | 理由 | -| ---------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR0 → PR1 → 合宿系の順にする | 全体 | PR0 は DB に依存しない。PR1 は出典欄の成果がすぐ見え、欠落が少ないので対応表の形式を楽なケースで決められる。逆順はレビュー負荷が低いが、出典欄の反映が遅れ、対応表の形式を最も判断の重い合宿系で作ることになる | - -### 移行 SQL - -| 決定 | PR | 理由 | -| ----------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 対応表を焼き込まず、基本ルールを SQL で表す | PR1 | 本番のタスクは管理画面の取り込みで入っており、seed と一致する保証が無い。`VALUES` で書くと本番にしか無い行が残り、`UPDATE` は該当行が無くてもエラーにならないので、取りこぼしが静かに起きる | -| 基本ルールで振り、8 コンテストはコンテストの全行を上書きする(45 行) | PR2 | 8 コンテストは先頭や途中の問題が AOJ に無く、基本ルールでは記号がずれる。正しい記号は、メンテナのスプレッドシートの位置(欠けた問題を飛ばした位置)で決まる。全行を上書きするのは、1 つのコンテストに 2 つの規則を混ぜないため | -| 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る | PR1 / PR2 | 合宿系も模擬国内と同じ `AOJ_JAG` に分類される。PR1 で `contest_type` だけで絞ると合宿系 467 件まで更新し、title のプレフィックスが表に出る。PR2 は `^JAG(Summer\|Winter\|Spring\|Regional)` で絞り、PR1 で記号にした模擬国内を外す | -| 各ステップの更新件数を DO ブロックで検査し、想定外なら RAISE する | PR2 | UPDATE は対象が 0 件でもエラーにならない。想定値は、空の DB(`prisma migrate reset`)の 0、模擬地区の無い開発 DB の 467 / 32 / 467、staging・本番の 625 / 45 / 467 | -| 件数は固定値のまま。模擬地区が一部だけの DB では RAISE し、エラー文で「全部取り込むか、全部消す」と案内する | PR2 | 本番で範囲の誤りを止める力を保つため。模擬地区だけ実際の件数に合わせる案は、範囲の誤りを見逃しやすくなるので採らなかった | -| 順位はコンテストの全行で付け、更新は問題 ID のままの行だけにする | PR2 | 後から記号付きで入る行(PR2.1)が先にあっても触らないため。順位を付ける側でも絞ると、記号の行と問題 ID の行が混ざったコンテストで A から振り直してしまう(Codex の指摘) | -| title から外すのは「自分の `task_id` + `. `」で始まるものだけ | PR2 | `^[0-9]+\. ` で外すと、番号で始まる本来の title まで削りうる | -| `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない | PR1 | 本番でも index は既に正しい。AOJ に 1 問しか無いので、大学系に範囲を広げるときは基本ルール(`A` になる)の対象から外す | - -分かったこと: - -- **SQL**(PR1): `row_number()` は `bigint` を返し、`chr(bigint)` は存在しないので `::int` が要る。`EXPLAIN` は `task` の Seq Scan 2 回 + Hash Join(`contest_type` に索引が無いため想定どおり) -- **トランザクション**(PR1): マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで、1–2 文目も残らないことをローカルで確認) -- **ローカルでの移行**(PR2): 管理画面で模擬地区を取り込み、本番と同じ状態にしてから確かめた。`SELECT` のプレビューは 625 行すべてで照合の結果と一致し、適用後もプレビューと 625 / 625 で一致した。範囲外の AOJ(PCK、大学系、PR1 の範囲)は変わらない。`EXPLAIN` は CTE 側の Seq Scan 1 回と、`task_task_id_key` の Index Scan を使う Nested Loop -- **Codex の指摘の再現**(PR2): 記号の行と問題 ID の行が混ざったコンテストを読み取り専用で再現すると、直す前の SQL は A から振り直し、直した後は B, C, D と正しく振った。ローカルは移行前のスナップショットに戻し、直した移行を適用し直した(625 / 625)。「`task_id` だけで結合している」という指摘は、`task_id` が schema で `@unique` なので該当しない -- **staging**(PR2): 当初は模擬地区が 2 コンテスト / 16 問しか無く、件数チェックで RAISE するところだった。PR1.5 の後に staging で取り込み、PR を開く前に本番と同じ状態(模擬地区 16 / 158、合宿系 50 / 467、index はすべて問題 ID、title の問題 ID は合宿系だけ)であることを確かめた。#4220 で反映した(2026-10-25)。移行の後に Vercel のデプロイが走るので、件数チェックも通っている - -### 照合(記号の対応表) - -- **PR1**: メンテナのスプレッドシートを 2026-10-21 のスクリーンショットから書き写し、632 問すべてが基本ルールの記号の位置で一致した(補正なし)。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に 1123 として存在するが、コンテスト一覧に無いので取り込まれていない(末尾なので記号はずれない。PR3 の対象) -- **PR2**: スプレッドシートの書き写し(66 コンテスト)と 625 問の title が、記号の位置ですべて一致した。補正は 8 コンテスト / 45 行(事前の目視どおり) -- **PR2.1**: 114 問すべてが、スプレッドシートの問題名と記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で欠落が無く、基本ルールと一致する -- **照合スクリプト**: PR1 は `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`、PR2 は `git show c605d3bd:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_jag_problem_letters.ts`。`prisma/tasks.ts` を import し、件数が既知の値と違えば止まる。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。スプレッドシート自体の誤りは検出できない -- **スクリプトの検出力**: わざと誤りを入れたコピーを、PR1 / PR2 とも 5 種類すべて検出した。PR1 は順番の入れ替え、途中の title の誤り、途中の欠番、問題数の不足、切れた title の前半の誤り。PR2 は欠けた記号を外す、順番の入れ替え、途中の title の誤り、欠けた数の不足、切れた title の前半の誤り - -### 取り込み(管理画面・AOJ API) - -| 決定 | PR | 理由 | -| ---------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 取り込みでは、対応表ではなく基本ルールで記号を付ける(M5) | PR1 | 対応表を AOJ client に置く案は、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる | -| 取り込みでは補正しない(M14) | PR2 | 8 コンテストは本番・staging・seed のすべてにあり、取り込みは既存の問題を skip する。Codex も「取り込みで補正すべき」と指摘したが、同じ理由で採らなかった | -| 合宿系は管理画面の取り込みに足さない | PR2.1 | 合宿系も AOJ API の JAG の filter(summer / winter / spring)にあるが、既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加には重い。応答の形は意図的に確かめていない(メンテナの判断) | -| AOJ API はベース URL だけを変える | PR1.5 | AOJ Developers の告知(2026-09-07 に Main API が移転)で変わったのはホストだけ。メンテナが新しい API をブラウザで開き、取り込みが使う 8 つのエンドポイント(`courses`、`problems?size=10000`、`challenges` の 6 種別)のパスと応答の形が fixture と同じことを確かめた | - -分かったこと: - -- **2 日開催の JAG 模擬国内**(PR1): AOJ API には `JAGPrelim20162`(2738–2751)のように 2 日を 1 コンテストにしたものがある。取り込みは `contest_id` 単位で振るので A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存の問題を skip するので影響しない -- **応答の違い**(PR1.5): 値の違いは最上位の `largeCl.id` が小文字になったこと(`"JAG"` → `"jag"` など)だけ。コードはここを読まない。fixture に無い回(ICPC 国内予選 2026、JAG 模擬国内 2026、PCK 予選 2026 など)は、fixture の記録(2026-05)の後に AOJ に追加されたもの -- **ベース URL の落とし穴**(PR1.5): `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった -- **問題ページへのリンク**(PR1.5): `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける(メンテナが確認) -- **staging(Vercel)での取り込み**(PR1.5): 2026-10-24 にメンテナが JAG REGIONAL を取り込み、16 / 158 になった(PR2 の前提)。こちらの取得ツールが新しいホストに `ECONNREFUSED` で拒まれたことから送信元の制限を心配していたが、Vercel からは問題なく取得できた - -### 分類と表示 - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| AOJ ラベルから全角括弧を外す(M11) | PR0 | 出典欄の目標書式ではコンテスト名を括弧で囲まない。ラベルは検索・投票・タグ・取り込み画面・提出状況の更新モーダルでも使われており、その表示も変わるが許容した | -| 表示側の変換と `getTaskLabels` を削除する(M7 / M10) | PR1 | DB が記号を持てば変換は恒等になる。インターフェイスの `getTaskLabels` だけを残す案は、上書きする Provider が 0 になり使われないコードが残るので採らなかった | -| ヘッダーの並びは `localeCompare`(M4) | PR1 | 記号に `Number()` を当てると NaN になり、ソートが効かなくなる | -| `buildTitleWithTaskIndex` は「外してから付ける」 | PR1 | AtCoder 側の title は `A. …` を含むので、付けるだけだと二重になる。title にプレフィックスの無い AtCoder の問題は `abs` の practice_1 だけで(本番も同じ)、ABS は index を表示しない | -| `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す(レビュー) | PR1 | 記号は title の本文にも現れうる。以前は index が空のとき `Dr. Podboq` → `DrPodboq` のように本文の `. ` まで消していた | -| `ATCODER_JAG` を新設し、4 つを contest_id の完全一致で分類する(M17) | PR2.1 | `jag` の前方一致だと、AtCoder Problems の `JAG2013Spring` / `JAG2014Spring`(AOJ の `JAGSpring2013` / `2014` と同じコンテスト)まで入り、問題が二重になる。`OTHERS` に入れないのはメンテナの判断。`AOJ_JAG` だと出典欄の `(AOJ)` や `getTaskUrl` が AOJ を前提に動く | -| ラベル(出典欄)に(有志)を付けない | PR2.1 | PR2.5 のテーブル名でだけ、2018 Day2 / 2017 Day1 に付ける(M16) | -| 合宿系・模擬地区・AtCoder 上の JAG を 1 グループにし、テーブルはコンテストごとにする(M15) | PR2.5 | 同じ年の中の前後関係(模擬地区 2014 は夏合宿 2014 の Day3 として開催)を表せるのは 1 グループだけ。既存の「JAG 模擬国内」とは統合しない(年のループで完成しており、統合するとそれも配列に置き換えることになる) | -| 並びは明示的な配列で持つ | PR2.5 | スプレッドシートの行順(新しい順)は contest_id から導けない(冬合宿 2010 は夏合宿 2011 の後、など)。今後の合宿は seed と配列の両方に足す(配列の書き漏れは seed との整合テストで落ちる) | -| テーブル名は `getContestNameLabel` の名称に括弧書きを付け、出典欄は変えない(M16) | PR2.5 | スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にすると、出典欄と食い違う。出典欄もそろえると、ラベルを使う検索・投票・タグなどと fixture に波及するので #4018 に回した。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない | -| Provider の `contestType` は、AtCoder 上の 4 コンテストだけ `ATCODER_JAG`、ほかは `AOJ_JAG` | PR2.5 | 計画では、PR2.1 と順序を問わないように全件 `AOJ_JAG` にしていた。PR2.1 が先に staging に入ったので、実際の分類に合わせた(メンテナの判断)。`contestType` が効くのは provider key と `getTaskTableHeaderName`(違いは ABC の H/Ex だけ)なので、表示は変わらない | -| グループのキーは `aojJagRegional` | PR2.5 | キーは localStorage(`contest_table_providers`)に保存され、後から変えると利用者の選択が消える。`aojJagPrelim` と対にし、配列のファイル・型・定数(`aoj_jag_regional_entries.ts`)とクラス(同じファイルの `JagPrelimProvider` と対の `JagRegionalProvider`)もこの名前にそろえた(メンテナの判断) | +## データ移行の共通手順 -分かったこと: +PR2(記号の移行)、PR2.1(enum の追加)、PR3 に当てはまる。 -- **表示の回帰**(PR1): 旧コードで適用前のデータを、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 -- **Codex のレビュー**(PR2.5): 指摘は 2 件で、上の空の枠(残タスクへ)と、plan.md の「PR2.1 は PR2.5 と前後してよい」が古いこと(直した)。Codex が確かめられなかった 2 点はこちらで確かめた。AtCoder の 4 コンテストのセルは AtCoder に飛ぶ(`getTaskUrl` が AOJ に振るのは大文字の `JAG…` だけ)。title の記号は二重にならない(`buildTitleWithTaskIndex` が先頭の `A. ` を外してから付ける) -- **DB の `contest_type`**(PR2.1): 書き込むだけで、表示などで読む箇所は無い(分類は毎回 `contest_id` から引く)。enum の値は `ADD VALUE` で DB 上の末尾に足され、schema.prisma の並びと違うが、並べ替えにも使っていない +### CI での適用 -### seed とデータの投入 +`.github/workflows/ci.yml`(2026-10-24 に確認)は、build / lint / check / unit-test が通ると、`main` 以外では `preview` ジョブ(適用先は staging の DB)、`main` では `production` ジョブを走らせる。どちらも `vercel build` の後・デプロイの前に `prisma migrate deploy` を流す。 -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 前後に空白のある title(本番 46 件、3355 の末尾のタブを含む)は直さない | PR2 | 表示では空白が潰れ、完全一致で比べるコードも無い。取り込みが trim するので再発もしない([スコープ外](#スコープ外)) | -| staging / 本番の 2025 / 2026 夏合宿は、PR2 の移行の後に Supabase の CSV で記号付きで入れる | PR2.1 | 移行の前に問題 ID のまま入れると、PR2 の件数の想定値が変わる。記号付きなら移行と独立する | -| CSV の `id` は既存の合宿系と同じ `sha256(contest_id + title)` | PR2.1 | 取り込みの `sha256(contest_id + task_id)` とは違うが、`Task.id` を再計算して引く箇所が無い([根拠](./investigation.md#id-の採番規則が経路ごとに違う)) | -| seed の AOJ 72 行は古い順・問題 ID の昇順に置く | PR2.1 | add-tasks の規則(新しい順・降順)は AtCoder 側の慣習で、既存の AOJ のブロックはすべて昇順。Codex の指摘は採らなかった | +- **PR の CI でも staging の DB に適用される**。`preview` ジョブの条件が `github.ref != 'refs/heads/main'` だけで、PR の ref も当たる。staging へのマージより前である +- このため SQL の確認は PR を開く前にローカルで済ませる。適用済みのマイグレーションは編集しても再適用されないので、後から誤りが見つかったら新しいマイグレーションを積む +- `main` へ昇格した時点で production に適用されるので、staging での確認が自然にゲートになる +- 本件の範囲外だが、`preview` ジョブの「Assign staging domain」はコメントに「if staging branch」とあるのに条件は `github.ref != 'refs/heads/main'` で、PR のデプロイにも staging のドメインが付け替わる -分かったこと: +### 手順 -- **seed の模擬地区**(PR2): 本番から取り出した 158 問は、AOJ の fixture(2026-05 記録)と問題 ID・コンテスト・名前がすべて一致した -- **ローカルの取り込み**(PR2.1): 2026-10-25 にメンテナが AtCoder の 4 コンテストを取り込み、42 行が task_id・contest_id・記号・title まで seed と一致した -- **staging への投入**(PR2.1): 2026-10-25 にメンテナが、管理画面から AtCoder の 4 コンテストを取り込み、2025 / 2026 夏合宿の CSV を投入した。10 コンテストの行は `ATCODER_JAG` 42 行・`AOJ_JAG` 72 行で、想定どおり +1. `pnpm exec prisma migrate dev --create-only --name `、SQL を `migration.sql` に書く +2. ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する。コンテストの切り替わり目(記号が `A` に戻る箇所)と問題数の少ない古いコンテストは目視する価値がある +3. `pnpm exec prisma migrate dev` で適用し、表示を確認する(**ここまで必ず PR を開く前に**) +4. PR を開く → staging へマージして確認 → `main` へ昇格 -#### PR2.1 をロールバックするとき +staging が防げないのは、対応表・欠落判断そのものの誤り。誤った記号でも SQL は完走し、それらしく表示される(難所 2)。 -取り込みの後に PR2.1 より前の版へ戻すと、旧版の Prisma Client は `ATCODER_JAG` を読めない。取り込みは既存の task_id を skip するので、戻した値はロールフォワードしても残る。 +### 事故対策 -1. ロールバックの前に、該当行を `OTHERS` に変え、返った `expected_count` を環境名とともに控える(全問を取り込んでいれば 42)。復旧が終わるまで、4 コンテストの追加・削除・分類の変更はしない +- **爆発半径**: 一括で復元できるのは AOJ ICPC / JAG だけ(`task_table_index == task_id` が成り立っていたため)。WHERE 句が壊れて AtCoder 系に波及すると一括復元の手段が無くなる([実測](./investigation.md#復元可能性の実測)) +- **復元**: 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる。再修正時は Supabase Pro の PITR が唯一の手段 +- **`updated_at`**: `@updatedAt` は Prisma Client 側の実装で、生 SQL では更新されない。意図的に含めない +- **キャッシュ**: TTL 1 時間(`src/lib/clients/cache.ts`)。生 SQL と CSV の投入は `invalidateTaskCaches()` を経由しない。「更新したのに表示が変わらない」ときはこれを疑う +- **トランザクション**: マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで 1–2 文目も残らないことをローカルで確認) +- **デプロイ直前の数分**: マイグレーションの適用から Vercel のデプロイ完了までは、旧コードが新しいデータを描画する。数分で解消するので許容する -```sql -WITH changed AS ( - UPDATE "task" - SET "contest_type" = 'OTHERS' - WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') - AND "contest_type" = 'ATCODER_JAG' - RETURNING "id" -) -SELECT count(*) AS expected_count FROM changed; -``` +### スプレッドシートとの照合 -2. ロールフォワードでは、PR2.1 以降をデプロイしてから、`expected_count` を控えた件数に置き換えて実行する。件数が違えば `RAISE` で UPDATE ごと取り消されるので、期待値を書き換えて通さず、記録と対象行を調べる。最後の SELECT が `ATCODER_JAG` の 1 行(件数は控えた値)だけを返せば復旧完了。控えた件数が 0 なら SELECT も 0 行になる。`OTHERS` など別の分類が残れば、復旧は終わっていない +1. 基本ルールで対象範囲の記号を生成する +2. コンテスト単位で、メンテナのスプレッドシートと照合する(人手が要るのは書き写しだけ) +3. 先頭・途中に欠落があるコンテストは、スプレッドシートの位置から記号を決める(末尾の欠落は記号がずれないので補正不要) +4. 欠落した問題が AOJ に問題番号で存在するかを確かめ、PR3 に引き継ぐ -```sql -DO $$ -DECLARE - expected_count integer := 42; -- Replace with the recorded rollback count. - restored_count integer; -BEGIN - UPDATE "task" - SET "contest_type" = 'ATCODER_JAG' - WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') - AND "contest_type" = 'OTHERS'; - - GET DIAGNOSTICS restored_count = ROW_COUNT; - IF restored_count <> expected_count THEN - RAISE EXCEPTION 'restored % rows, expected %', restored_count, expected_count; - END IF; - RAISE NOTICE 'restored % rows, expected %', restored_count, expected_count; -END $$; - --- Expected: ATCODER_JAG only, with the recorded rollback count. -SELECT "contest_type", count(*) FROM "task" -WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') -GROUP BY "contest_type"; -``` +- 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても前詰めでずれた記号が出るだけで、表示は成立する +- スクリプトには総数のアサーションを入れる。抽出漏れが欠落の誤検出に直結する([事例](./investigation.md#6-調査中の誤りと訂正自戒))。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。スプレッドシート自体の誤りは検出できない +- PR1 / PR2 のスクリプトは git に残っている: `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`、`git show c605d3bd:…/generate_aoj_jag_problem_letters.ts`。わざと誤りを入れたコピー 5 種類(順番の入れ替え、途中の title の誤り、欠番、問題数の不足、切れた title の前半の誤り)を、どちらも検出した -### テスト +## 実装中に分かったこと -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| seed の検査は、補正した 8 コンテストを literal で、title を「問題名(`name`)と同じ」で見る | PR2 | 補正の正しさは基本ルールの関数では検査できない。title は「問題 ID が前に付いていない」だけだと、空や別の問題の title にすり替わっても通る(Codex の指摘) | -| AOJ の fixture は取り直さない | PR1.5 | 応答の形が同じため。取り直すと統計値(`solvedUser` など)や新しい回の追加で差分が出て、fixture に依存するテストの期待値まで変わる | -| AOJ のテストは nock のまま、クライアントを本番の定数から作る | PR1.5 | CI で実 API を叩かないために nock でモックするのはメンテナの意図どおりで、そのまま残す。問題は、テストが自前の旧ホストからクライアントを作っていて、定数を間違えても通ることだった | -| `nock.disableNetConnect()` を足す | PR1.5 | モックを書き忘れたリクエストが、黙って実 API に飛ぶのを防ぐ | -| 配列の contest_id を、seed の JAG 合宿系・模擬地区・AtCoder 上の JAG と突き合わせる | PR2.5 | 計画のテスト(件数・先頭と末尾・2014 の並び・括弧書き)だけでは、手書きの 76 件の id の誤り(そのテーブルが黙って表示されなくなる)を検出できない(メンテナの判断で追加) | +コードと git から読み取れない事実だけを残す。 -分かったこと(`verify-test-strength` による変異テスト): +**移行 SQL(PR1 / PR2)** -- **PR1**: 15 個の変異をすべて検出した。seed 整合テストの `ICPC Prelim 2024 F is problem 1677` は単独で検出する変異が無いが、唯一の literal な期待値(対応表とデータが一緒にずれたときの歯止め)なので残した -- **PR1.5**: `AOJ_API_BASE_URL` の 4 つの変異(末尾の `/` を外す、旧ホストに戻す、`/api/v1/` にする、`/api` を外す)は、どれも 26 件すべてのテストで検出された -- **PR2**: 6 つの変異をすべて検出した(JAG の記号を PRELIM だけ / ICPC だけにする、補正した記号を基本ルールに戻す、基本ルールのコンテストで A・B を入れ替える、title に問題 ID を付け直す、模擬地区の記号を問題 ID に戻す)。title のテストは、空の title・別の問題の title・問題 ID の付け直しも検出する -- **PR2.1**: 9 つの変異をすべて検出した(seed の記号の入れ替え・行の欠落・title の不一致、map の欠落、優先度の入れ替え、`jag` / `jag2` の前方一致、4 つのキーでの前方一致、ラベルの定数化)。4 つのキーでの前方一致は当初見逃しており、Codex の指摘でテストを足した -- **PR2.5**: 11 個の変異をすべて検出した(AtCoder の 4 件を `AOJ_JAG` にする、分類の判定を反転する、括弧を半角にする、括弧書きを常に付ける、`abbreviationName` を定数にする、provider key から contest_id を外す、2014 の模擬地区を Day4 の後に動かす、配列の行の欠落・先頭の行の欠落・id の誤り・id の重複)。テストで並びを見ているのは先頭・末尾・2014 の 3 件だけなので、配列の並びと括弧書きは investigation.md の表と機械的に突き合わせ、76 件すべて一致した +- 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る。合宿系も模擬国内と同じ `AOJ_JAG` に分類されるので、PR1 で `contest_type` だけで絞ると合宿系 467 件まで更新する。PR2 は `^JAG(Summer|Winter|Spring|Regional)` で絞る +- PR1 は対応表を焼き込まず基本ルールを SQL で表した。本番は管理画面の取り込みで入っていて seed と一致する保証が無く、`UPDATE` は 0 件でもエラーにならないので、取りこぼしが静かに起きる +- PR2 の補正 8 コンテスト(45 行)は、コンテストの全行を上書きして 1 つのコンテストに 2 つの規則を混ぜない。各ステップの更新件数を DO ブロックで検査し、想定外なら RAISE する(想定値: 空の DB 0、模擬地区の無い開発 DB 467 / 32 / 467、staging・本番 625 / 45 / 467)。模擬地区が一部だけの DB では RAISE し、エラー文で「全部取り込むか、全部消す」と案内する +- 順位はコンテストの全行で付け、更新は問題 ID のままの行だけにする(Codex の指摘)。順位を付ける側でも絞ると、記号の行と問題 ID の行が混ざったコンテストで A から振り直してしまう。title から外すのは「自分の `task_id` + `. `」で始まるものだけ(`^[0-9]+\. ` だと番号で始まる本来の title まで削る) +- `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない +- `row_number()` は `bigint` を返し `chr(bigint)` は無いので `::int` が要る +- PR2 の staging は当初、模擬地区が 2 コンテスト / 16 問で件数チェックが RAISE するところだった。PR1.5 の後に staging で取り込み、本番と同じ状態(16 / 158、合宿系 50 / 467)にそろえてから PR を開いた -### 開発環境(dev container) +**取り込みと AOJ API(PR1 / PR1.5)** -| 決定 | PR | 理由 | -| ------------------------------------------------------ | ----- | --------------------------------------------------------------------------------------------------- | -| 許可リストの旧ホストは残さず、新しいホストに置き換える | PR1.5 | 旧ホストは 410 を返すだけで使い道が無い。許可先はデータを持ち出せる経路でもあるので、数を増やさない | +- AOJ API には 2 日開催を 1 コンテストにしたもの(`JAGPrelim20162`、2738–2751)があり、取り込みでは A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれていて、取り込みは既存の問題を skip するので影響しない +- 新しい API とのコードの差はベース URL だけで、応答は最上位の `largeCl.id` が小文字になっただけ(コードは読まない) +- `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった +- 取得ツールが新しいホストに `ECONNREFUSED` で拒まれたが、Vercel からは問題なく取得できた。問題ページ `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける +- テスト: クライアントを nock のまま本番の定数から作り(以前は自前の旧ホストで、定数を間違えても通った)、`nock.disableNetConnect()` を足した。AOJ の fixture は取り直さない(統計値や新しい回の追加で、fixture に依存する期待値まで変わるため) -分かったこと(ローカルの管理画面の取り込みが 500 になった原因。ブラウザにはどちらも「Failed to load resource: 500」とだけ出る): +**分類と表示(PR1 / PR2.1 / PR2.5)** -- **許可リスト**(PR1.5): 開発サーバーはコンテナの中で動き、外向き通信は許可リスト方式で、新しいホスト(163.143.87.177)が入っていなかった(旧ホストは 163.143.87.219)。サーバーの `fetch` が接続で拒否され、管理画面のアクションが `fail(500)` を返していた。ブラウザはホストの Mac から直接開くので見えていた。`init-firewall.sh` は Dockerfile の `COPY` でイメージに入り、起動時に 1 回だけ DNS で IP を引くので、編集を反映するにはコンテナの再起動ではなく再ビルドが要る。再ビルドの後、2026-10-24 にメンテナがローカルで AOJ から取得できることを確かめた -- **古い Prisma Client**(PR2.1): enum を足す前から動いている開発サーバーは、古い Prisma Client を持ったままで、`ATCODER_JAG` の登録が 500 になる。Vite はソースの変更を反映するが `node_modules` は読み込み直さないので、一覧には新しいコンテストが出る。開発サーバーの再起動で直る +- 表示の回帰: 旧コードで適用前、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 +- AtCoder の 4 コンテストのセルは AtCoder に飛ぶ(`getTaskUrl` が AOJ に振るのは大文字の `JAG…` だけ)。title の記号は二重にならない +- DB の `contest_type` は書き込むだけで、読む箇所は無い(分類は毎回 `contest_id` から引く)。enum の値は `ADD VALUE` で DB 上の末尾に足され、schema.prisma の並びと違うが、並べ替えにも使っていない +- PR2.5 のテストは、配列の contest_id を seed の合宿系・模擬地区・AtCoder 上の JAG と突き合わせる(手書きの 76 件の誤りは、計画の件数・並びのテストでは検出できない)。並びと括弧書きは investigation.md の表と機械的に突き合わせ、76 件すべて一致した -分かったこと(ローカルでテーブルが出ない): +**seed とデータの投入(PR2 / PR2.1)** -- **seed の流し直し**(PR2.5): PR2.1 より前に seed したローカル DB には 2025 / 2026 の夏合宿が無く、「JAG 模擬地区」にテーブルが出なかった。seed は既存の行を skip して足りない行だけ入れるので、`pnpm db:seed` を流し直せば入る +- seed の模擬地区 158 問は本番から取り出したもので、AOJ の fixture と ID・コンテスト・名前がすべて一致した +- 2025 / 2026 夏合宿は、PR2 の移行の後に CSV で記号付きで入れる(移行の前に問題 ID のまま入れると PR2 の件数の想定値が変わる)。CSV の `id` は既存の合宿系と同じ `sha256(contest_id + title)` +- seed の AOJ 72 行は、既存の AOJ のブロックにそろえて古い順・問題 ID の昇順に置いた(add-tasks の新しい順・降順は AtCoder 側の慣習) +- PR2.1 のデータは、2026-10-25 にメンテナがローカルと staging で投入・確認した(10 コンテストで `ATCODER_JAG` 42 行・`AOJ_JAG` 72 行) +- PR2.1 より前の版へロールバックすると、旧版の Prisma Client は `ATCODER_JAG` を読めない。ロールバックの前に該当 42 行を `OTHERS` に変えて件数を控え、ロールフォワードの後に件数チェック付きの UPDATE で戻す。件数が違えば RAISE させ、期待値を書き換えて通さない(手順の SQL は git の履歴にある) -### PR3 の実装中に分かったこと +**開発環境** -- **実装中の変更**: `task_index_label.test.ts` の既存ケースが約 80 件あるので、fixture の `taskId` は省略できる項目にし、テスト側で通常の ID を補った。必須にしたのは関数の引数だけ -- **確認の手段**: seed との突き合わせのテスト 2 件(ダミーは全件 map にある・map のキーは全件 seed にある)が、map の漏れと打ち間違いを守る。CSV の記号は投入後の SQL で確かめる +- ローカルの管理画面の取り込みが 500 になった原因は 2 つ。外向き通信の許可リスト(`.devcontainer/init-firewall.sh`)に新しいホストが無かったこと(起動時に 1 回だけ DNS で IP を引くので、反映にはコンテナの再ビルドが要る。旧ホストは残さず置き換えた)と、enum を足す前から動いている開発サーバーが古い Prisma Client を持っていたこと(再起動で直る) +- PR2.1 より前に seed したローカル DB には 2025 / 2026 の夏合宿が無く、「JAG 模擬地区」にテーブルが出ない。`pnpm db:seed` を流し直せば入る From ac26235c3a4adfeb32b59da5a98c99ce1fcf438a Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 09:57:52 +0000 Subject: [PATCH 11/15] docs: refine task title index plan Co-Authored-By: Claude Sonnet 5.5 --- .../task-title-index-change-impact/plan.md | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index ed0293c61..de9126120 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -1,6 +1,6 @@ # AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する -**日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装済み、未マージ。根拠データは [investigation.md](./investigation.md)。 +**日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装・目視確認済み、未マージ。根拠データは [investigation.md](./investigation.md)。 ## ゴール @@ -17,15 +17,15 @@ AOJ ICPC・JAG の全範囲(ICPC 国内予選 / アジア地区 / JAG 模擬国内 79 コンテスト、合宿系・模擬地区・in AtCoder 76 コンテスト)で `task_table_index` が問題記号になった。title は素(問題 ID も記号も付かない)で、セルは `task_table_index` から `A. タイトル` を組み立てる。 -| PR | 内容 | 状態 | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | -| PR0 | 出典欄の AOJ の書式(#4212) | 完了 | -| PR1 | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を記号にする。表示側の変換を削除し、取り込みは基本ルールで記号を付ける(#4217) | 完了 | -| PR1.5 | AOJ API の移転(2026-09-07、旧ホストは 410)に追従する(#4219) | 完了 | -| PR2 | 合宿系 + 模擬地区(66 / 625)を記号にし、合宿系の title から問題 ID を外す(#4220) | 完了 | -| PR2.1 | `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテストと 2025 / 2026 の夏合宿(計 10 コンテスト)を足す(#4222) | 完了(本番のデータも投入済み) | -| PR2.5 | グループ「JAG 模擬地区」に 76 コンテストのテーブルを作る | 完了 | -| PR3 | AOJ のコンテスト一覧に無い 26 問を足す(AOJ に無い 21 問はダミー) | 実装済み、未マージ。CSV の投入待ち | +| PR | 内容 | 状態 | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | +| PR0 | 出典欄の AOJ の書式(#4212) | 完了 | +| PR1 | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を記号にする。表示側の変換を削除し、取り込みは基本ルールで記号を付ける(#4217) | 完了 | +| PR1.5 | AOJ API の移転(2026-09-07、旧ホストは 410)に追従する(#4219) | 完了 | +| PR2 | 合宿系 + 模擬地区(66 / 625)を記号にし、合宿系の title から問題 ID を外す(#4220) | 完了 | +| PR2.1 | `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテストと 2025 / 2026 の夏合宿(計 10 コンテスト)を足す(#4222) | 完了(本番のデータも投入済み) | +| PR2.5 | グループ「JAG 模擬地区」に 76 コンテストのテーブルを作る | 完了 | +| PR3 | AOJ のコンテスト一覧に無い 26 問を足す(AOJ に無い 21 問はダミー) | 実装・目視確認済み、未マージ。CSV の投入待ち | 順序の制約: @@ -37,10 +37,11 @@ AOJ ICPC・JAG の全範囲(ICPC 国内予選 / アジア地区 / JAG 模擬 PR3: -1. 開発サーバーで JAG 冬合宿 2006 Day2 の表と問題一覧を開き、ダミーのリンク先と `(No Judge)` を目で確かめる -2. PR を開き、staging へマージする(Codex のクロスレビューは済み。指摘はテストの強化 1 件で反映済み) -3. コードのデプロイの後に、メンテナが staging へ CSV を投入し、[SQL](#staging--本番への投入メンテナ) で確かめる -4. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる +実装・Codex のクロスレビュー(指摘はテストの強化 1 件で反映済み)・開発サーバーでの目視(JAG 冬合宿 2006 Day2 の表と問題一覧で、ダミーのリンク先と `(No Judge)` を確認)は済んだ。 + +1. PR を開き、staging へマージする +2. コードのデプロイの後に、メンテナが staging へ CSV を投入し、[SQL](#staging--本番への投入メンテナ) で確かめる +3. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる PR 外(優先度の低いものを含む): From 4926b94808aeeec944a87bd8733d0a37995f2d1e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 11:27:32 +0000 Subject: [PATCH 12/15] docs: extend task title index plan Co-Authored-By: Claude Sonnet 5.5 --- .../task-title-index-change-impact/plan.md | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index de9126120..0ccbefedc 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -46,7 +46,7 @@ PR3: PR 外(優先度の低いものを含む): - 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る(`getContests` 側もそろえる) -- seed 整合テストの期待値を `assignProblemLetters` http://127.0.0.1:5173/で作っていて、testing.md の「期待値は literal」に合わない。補正したコンテストだけ literal にしている。全件を literal にするかは未判断 +- seed 整合テストの期待値を `assignProblemLetters` で作っていて、testing.md の「期待値は literal」に合わない。補正したコンテストだけ literal にしている。全件を literal にするかは未判断 - `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので触っていない - テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める([名称の違い](./investigation.md#名称の違い)) - `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める @@ -174,7 +174,10 @@ AOJ の JAG / ICPC で、公式の問題なのに seed に無い 26 問を足す | NoJudge-JAGWinter2006-day3_j | JAGWinter2006-day3 | AOJ_JAG | J | Wireing Assistant | | NoJudge-JAGWinter2007-day3_e | JAGWinter2007-day3 | AOJ_JAG | E | Networking Company | -CSV を入れたら、次の SQL で記号を確かめる。`(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える。seed はテストが守るが、CSV は守らない。 +CSV を入れたら、次の SQL で確かめる。seed はテストが守るが、CSV は守らない。 + +- 記号: `(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える +- 26 行の中身: 記号の集計だけでは `task_id` の打ち間違いが通る。`NoJudge-` の綴りを誤ると `(AOJ)` と開けない AOJ のリンクで出て、AOJ の ID を誤ると別の問題に飛ぶ。最後の SQL で `prisma/tasks.ts` の値と 1 行ずつ突き合わせる ```sql -- Expect no rows: a duplicated letter hides one problem in the contest table. @@ -190,6 +193,39 @@ FROM task WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') GROUP BY contest_id ORDER BY contest_id; + +-- Expect no rows: every inserted row matches prisma/tasks.ts (catches task_id typos the letter checks miss). +SELECT expected.* +FROM (VALUES + ('1123', 'ICPCPrelim2002', 'E', 'Enclosing Circles'), + ('NoJudge-JAGRegional2005_c', 'JAGRegional2005', 'C', 'Geometry?! Why Not??'), + ('NoJudge-JAGRegional2005_f', 'JAGRegional2005', 'F', 'University Rankings'), + ('NoJudge-JAGRegional2005_h', 'JAGRegional2005', 'H', 'Tiling Polygons'), + ('NoJudge-JAGRegional2008_j', 'JAGRegional2008', 'J', 'Colony Maintenance'), + ('2565', 'JAGRegional2013', 'A', 'Broken Audio Signal'), + ('2566', 'JAGRegional2013', 'B', 'Restore Calculation'), + ('2567', 'JAGRegional2013', 'C', 'SIRO Challenge'), + ('NoJudge-JAGSummer2006-day1_a', 'JAGSummer2006-day1', 'A', 'Monkey Business'), + ('NoJudge-JAGSummer2006-day1_c', 'JAGSummer2006-day1', 'C', 'Sort that Queue'), + ('NoJudge-JAGSummer2006-day3_a', 'JAGSummer2006-day3', 'A', 'Rolling Cubes'), + ('3643', 'JAGSummer2024-day3', 'G', 'Interactive String Guessing'), + ('NoJudge-JAGWinter2006-day2_a', 'JAGWinter2006-day2', 'A', 'Cells'), + ('NoJudge-JAGWinter2006-day2_b', 'JAGWinter2006-day2', 'B', 'Japan'), + ('NoJudge-JAGWinter2006-day2_c', 'JAGWinter2006-day2', 'C', 'Maximum'), + ('NoJudge-JAGWinter2006-day2_e', 'JAGWinter2006-day2', 'E', 'Payment System'), + ('NoJudge-JAGWinter2006-day2_g', 'JAGWinter2006-day2', 'G', 'Roof'), + ('NoJudge-JAGWinter2006-day2_h', 'JAGWinter2006-day2', 'H', 'Seminar Room'), + ('NoJudge-JAGWinter2006-day2_i', 'JAGWinter2006-day2', 'I', 'String Compression'), + ('NoJudge-JAGWinter2006-day3_b', 'JAGWinter2006-day3', 'B', 'Widget Factory'), + ('NoJudge-JAGWinter2006-day3_c', 'JAGWinter2006-day3', 'C', 'Hills'), + ('NoJudge-JAGWinter2006-day3_e', 'JAGWinter2006-day3', 'E', 'Safe Packing'), + ('NoJudge-JAGWinter2006-day3_f', 'JAGWinter2006-day3', 'F', 'The "Shortest" Pair of Paths'), + ('NoJudge-JAGWinter2006-day3_i', 'JAGWinter2006-day3', 'I', 'Counting Triangles'), + ('NoJudge-JAGWinter2006-day3_j', 'JAGWinter2006-day3', 'J', 'Wireing Assistant'), + ('NoJudge-JAGWinter2007-day3_e', 'JAGWinter2007-day3', 'E', 'Networking Company') +) AS expected(task_id, contest_id, task_table_index, title) +LEFT JOIN task USING (task_id, contest_id, task_table_index, title) +WHERE task.id IS NULL; ``` | `contest_id` | 問題数 | 記号 | From f6ca8638d4fe113303b83fd3b56bbb3d2503fc02 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 14:03:10 +0000 Subject: [PATCH 13/15] docs: revise task title index plan Co-Authored-By: Claude Sonnet 5.5 --- .../task-title-index-change-impact/plan.md | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index 0ccbefedc..9434e8730 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -177,7 +177,7 @@ AOJ の JAG / ICPC で、公式の問題なのに seed に無い 26 問を足す CSV を入れたら、次の SQL で確かめる。seed はテストが守るが、CSV は守らない。 - 記号: `(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える -- 26 行の中身: 記号の集計だけでは `task_id` の打ち間違いが通る。`NoJudge-` の綴りを誤ると `(AOJ)` と開けない AOJ のリンクで出て、AOJ の ID を誤ると別の問題に飛ぶ。最後の SQL で `prisma/tasks.ts` の値と 1 行ずつ突き合わせる +- 26 行の中身: 記号の集計だけでは `task_id` の打ち間違いが通る。`NoJudge-` の綴りを誤ると `(AOJ)` と開けない AOJ のリンクで出て、AOJ の ID を誤ると別の問題に飛ぶ。`contest_type` の列を CSV から落とすと、DB の既定値 `ABC` で黙って入る。最後の SQL で `prisma/tasks.ts` の値(`contest_type` は `classifyContest()` の結果)と 1 行ずつ突き合わせる ```sql -- Expect no rows: a duplicated letter hides one problem in the contest table. @@ -195,36 +195,37 @@ GROUP BY contest_id ORDER BY contest_id; -- Expect no rows: every inserted row matches prisma/tasks.ts (catches task_id typos the letter checks miss). +-- The first row casts contest_type to the enum; comparing text with "ContestType" fails. SELECT expected.* FROM (VALUES - ('1123', 'ICPCPrelim2002', 'E', 'Enclosing Circles'), - ('NoJudge-JAGRegional2005_c', 'JAGRegional2005', 'C', 'Geometry?! Why Not??'), - ('NoJudge-JAGRegional2005_f', 'JAGRegional2005', 'F', 'University Rankings'), - ('NoJudge-JAGRegional2005_h', 'JAGRegional2005', 'H', 'Tiling Polygons'), - ('NoJudge-JAGRegional2008_j', 'JAGRegional2008', 'J', 'Colony Maintenance'), - ('2565', 'JAGRegional2013', 'A', 'Broken Audio Signal'), - ('2566', 'JAGRegional2013', 'B', 'Restore Calculation'), - ('2567', 'JAGRegional2013', 'C', 'SIRO Challenge'), - ('NoJudge-JAGSummer2006-day1_a', 'JAGSummer2006-day1', 'A', 'Monkey Business'), - ('NoJudge-JAGSummer2006-day1_c', 'JAGSummer2006-day1', 'C', 'Sort that Queue'), - ('NoJudge-JAGSummer2006-day3_a', 'JAGSummer2006-day3', 'A', 'Rolling Cubes'), - ('3643', 'JAGSummer2024-day3', 'G', 'Interactive String Guessing'), - ('NoJudge-JAGWinter2006-day2_a', 'JAGWinter2006-day2', 'A', 'Cells'), - ('NoJudge-JAGWinter2006-day2_b', 'JAGWinter2006-day2', 'B', 'Japan'), - ('NoJudge-JAGWinter2006-day2_c', 'JAGWinter2006-day2', 'C', 'Maximum'), - ('NoJudge-JAGWinter2006-day2_e', 'JAGWinter2006-day2', 'E', 'Payment System'), - ('NoJudge-JAGWinter2006-day2_g', 'JAGWinter2006-day2', 'G', 'Roof'), - ('NoJudge-JAGWinter2006-day2_h', 'JAGWinter2006-day2', 'H', 'Seminar Room'), - ('NoJudge-JAGWinter2006-day2_i', 'JAGWinter2006-day2', 'I', 'String Compression'), - ('NoJudge-JAGWinter2006-day3_b', 'JAGWinter2006-day3', 'B', 'Widget Factory'), - ('NoJudge-JAGWinter2006-day3_c', 'JAGWinter2006-day3', 'C', 'Hills'), - ('NoJudge-JAGWinter2006-day3_e', 'JAGWinter2006-day3', 'E', 'Safe Packing'), - ('NoJudge-JAGWinter2006-day3_f', 'JAGWinter2006-day3', 'F', 'The "Shortest" Pair of Paths'), - ('NoJudge-JAGWinter2006-day3_i', 'JAGWinter2006-day3', 'I', 'Counting Triangles'), - ('NoJudge-JAGWinter2006-day3_j', 'JAGWinter2006-day3', 'J', 'Wireing Assistant'), - ('NoJudge-JAGWinter2007-day3_e', 'JAGWinter2007-day3', 'E', 'Networking Company') -) AS expected(task_id, contest_id, task_table_index, title) -LEFT JOIN task USING (task_id, contest_id, task_table_index, title) + ('1123', 'ICPCPrelim2002', 'AOJ_ICPC'::"ContestType", 'E', 'Enclosing Circles'), + ('NoJudge-JAGRegional2005_c', 'JAGRegional2005', 'AOJ_JAG', 'C', 'Geometry?! Why Not??'), + ('NoJudge-JAGRegional2005_f', 'JAGRegional2005', 'AOJ_JAG', 'F', 'University Rankings'), + ('NoJudge-JAGRegional2005_h', 'JAGRegional2005', 'AOJ_JAG', 'H', 'Tiling Polygons'), + ('NoJudge-JAGRegional2008_j', 'JAGRegional2008', 'AOJ_JAG', 'J', 'Colony Maintenance'), + ('2565', 'JAGRegional2013', 'AOJ_JAG', 'A', 'Broken Audio Signal'), + ('2566', 'JAGRegional2013', 'AOJ_JAG', 'B', 'Restore Calculation'), + ('2567', 'JAGRegional2013', 'AOJ_JAG', 'C', 'SIRO Challenge'), + ('NoJudge-JAGSummer2006-day1_a', 'JAGSummer2006-day1', 'AOJ_JAG', 'A', 'Monkey Business'), + ('NoJudge-JAGSummer2006-day1_c', 'JAGSummer2006-day1', 'AOJ_JAG', 'C', 'Sort that Queue'), + ('NoJudge-JAGSummer2006-day3_a', 'JAGSummer2006-day3', 'AOJ_JAG', 'A', 'Rolling Cubes'), + ('3643', 'JAGSummer2024-day3', 'AOJ_JAG', 'G', 'Interactive String Guessing'), + ('NoJudge-JAGWinter2006-day2_a', 'JAGWinter2006-day2', 'AOJ_JAG', 'A', 'Cells'), + ('NoJudge-JAGWinter2006-day2_b', 'JAGWinter2006-day2', 'AOJ_JAG', 'B', 'Japan'), + ('NoJudge-JAGWinter2006-day2_c', 'JAGWinter2006-day2', 'AOJ_JAG', 'C', 'Maximum'), + ('NoJudge-JAGWinter2006-day2_e', 'JAGWinter2006-day2', 'AOJ_JAG', 'E', 'Payment System'), + ('NoJudge-JAGWinter2006-day2_g', 'JAGWinter2006-day2', 'AOJ_JAG', 'G', 'Roof'), + ('NoJudge-JAGWinter2006-day2_h', 'JAGWinter2006-day2', 'AOJ_JAG', 'H', 'Seminar Room'), + ('NoJudge-JAGWinter2006-day2_i', 'JAGWinter2006-day2', 'AOJ_JAG', 'I', 'String Compression'), + ('NoJudge-JAGWinter2006-day3_b', 'JAGWinter2006-day3', 'AOJ_JAG', 'B', 'Widget Factory'), + ('NoJudge-JAGWinter2006-day3_c', 'JAGWinter2006-day3', 'AOJ_JAG', 'C', 'Hills'), + ('NoJudge-JAGWinter2006-day3_e', 'JAGWinter2006-day3', 'AOJ_JAG', 'E', 'Safe Packing'), + ('NoJudge-JAGWinter2006-day3_f', 'JAGWinter2006-day3', 'AOJ_JAG', 'F', 'The "Shortest" Pair of Paths'), + ('NoJudge-JAGWinter2006-day3_i', 'JAGWinter2006-day3', 'AOJ_JAG', 'I', 'Counting Triangles'), + ('NoJudge-JAGWinter2006-day3_j', 'JAGWinter2006-day3', 'AOJ_JAG', 'J', 'Wireing Assistant'), + ('NoJudge-JAGWinter2007-day3_e', 'JAGWinter2007-day3', 'AOJ_JAG', 'E', 'Networking Company') +) AS expected(task_id, contest_id, contest_type, task_table_index, title) +LEFT JOIN task USING (task_id, contest_id, contest_type, task_table_index, title) WHERE task.id IS NULL; ``` From 4d2c8ad0d05aa759fa1aec0e86454360d1107326 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 14:07:11 +0000 Subject: [PATCH 14/15] docs: tweak task title index plan Co-Authored-By: Claude Sonnet 5.5 --- .../task-title-index-change-impact/plan.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index 9434e8730..de90f8fb7 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -194,11 +194,10 @@ WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JA GROUP BY contest_id ORDER BY contest_id; --- Expect no rows: every inserted row matches prisma/tasks.ts (catches task_id typos the letter checks miss). --- The first row casts contest_type to the enum; comparing text with "ContestType" fails. +-- Read-only. Expect no rows: the 26 rows match prisma/tasks.ts (catches task_id typos the letter checks miss). SELECT expected.* FROM (VALUES - ('1123', 'ICPCPrelim2002', 'AOJ_ICPC'::"ContestType", 'E', 'Enclosing Circles'), + ('1123', 'ICPCPrelim2002', 'AOJ_ICPC', 'E', 'Enclosing Circles'), ('NoJudge-JAGRegional2005_c', 'JAGRegional2005', 'AOJ_JAG', 'C', 'Geometry?! Why Not??'), ('NoJudge-JAGRegional2005_f', 'JAGRegional2005', 'AOJ_JAG', 'F', 'University Rankings'), ('NoJudge-JAGRegional2005_h', 'JAGRegional2005', 'AOJ_JAG', 'H', 'Tiling Polygons'), @@ -225,7 +224,12 @@ FROM (VALUES ('NoJudge-JAGWinter2006-day3_j', 'JAGWinter2006-day3', 'AOJ_JAG', 'J', 'Wireing Assistant'), ('NoJudge-JAGWinter2007-day3_e', 'JAGWinter2007-day3', 'AOJ_JAG', 'E', 'Networking Company') ) AS expected(task_id, contest_id, contest_type, task_table_index, title) -LEFT JOIN task USING (task_id, contest_id, contest_type, task_table_index, title) +LEFT JOIN task + ON task.task_id = expected.task_id + AND task.contest_id = expected.contest_id + AND task.contest_type::text = expected.contest_type + AND task.task_table_index = expected.task_table_index + AND task.title = expected.title WHERE task.id IS NULL; ``` From b012566f0567959203758ec971d283d7929959ba Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 28 Oct 2026 22:08:46 +0000 Subject: [PATCH 15/15] docs: remove task title index investigation and plan Co-Authored-By: Claude Sonnet 5.5 --- .../investigation.md | 642 ------------------ .../task-title-index-change-impact/plan.md | 331 --------- 2 files changed, 973 deletions(-) delete mode 100644 docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md delete mode 100644 docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md deleted file mode 100644 index 06015ae92..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ /dev/null @@ -1,642 +0,0 @@ -# 調査の根拠データと検討の経緯 - -[plan.md](./plan.md) の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** - -| 章 | 内容 | 主に使う PR | -| ----------------------------------------------- | --------------------------------------------------- | --------------- | -| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、表示順 | PR2 / 2.1 / 2.5 | -| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | -| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | -| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | -| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | -| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | -| [7](#7-pr3-欠けている問題) | 欠けている問題の調査、ダミーの設計、影響範囲 | PR3 | - -PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [plan.md](./plan.md#実装中に分かったこと)。 - ---- - -# 1. PR2 の照合(2026-10-24) - -メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。目視の結果で、PR2 の照合スクリプト(書き写したスプレッドシートとの機械的な照合)でも同じ結果になった([plan.md](./plan.md#スプレッドシートとの照合))。 - -スプレッドシートで折りたたまれた行(40–50 ほか 19 か所)は JAG 模擬国内だけで、合宿系・模擬地区は含まない(メンテナに確認済み)。 - -## 対応の確認 - -- seed の合宿系 50 コンテストは、すべてスプレッドシートにある。問題名は記号の位置ごとに一致した -- 本番の模擬地区 16 コンテスト(2005〜2017 と 2020〜2022)も、すべてある。問題 ID は 2024〜3357 で、すべて数値 -- スプレッドシートにあって DB に無いのは 10 コンテスト([下記](#db-に無いコンテスト)) - -## 記号の補正が要るコンテスト - -基本ルール(問題 ID の昇順に A, B, C…)と、スプレッドシートの位置が食い違うコンテスト。いずれも先頭か途中に、AOJ に無い問題がある。末尾だけの欠落(模擬地区 2008 の J)は記号がずれないので含めない。 - -| contest_id | 問題 ID → 記号 | 欠けている記号 | -| -------------------- | ---------------------------------------------------------- | ------------------- | -| `JAGSummer2006-day1` | 2057 → B | A, C | -| `JAGSummer2006-day3` | 2067〜2072 → B〜G | A | -| `JAGWinter2006-day2` | 2073 → D、2074 → F | A, B, C, E, G, H, I | -| `JAGWinter2006-day3` | 2075 → A、2076 → D、2077 → G、2078 → H | B, C, E, F, I, J | -| `JAGWinter2007-day3` | 2122〜2125 → A〜D、2126〜2130 → F〜J | E | -| `JAGSummer2024-day3` | 3637〜3642 → A〜F、3644〜3647 → H〜K | G | -| `JAGRegional2005` | 2024 → A、2025 → B、2026 → D、2027 → E、2028 → G、2029 → I | C, F, H | -| `JAGRegional2013` | 2568〜2574 → D〜J | A, B, C | - -8 コンテスト / 45 行(合宿系 32、模擬地区 13)。基本ルールから値が変わるのは 32 行(合宿系 21、模擬地区 11)。 - -## DB に無いコンテスト - -PR2.1 で入れる。contest_id と問題番号は 2026-10-24 にメンテナから受け取った。 - -| スプレッドシートの見出し | contest_id | 問題(task_id) | 入手元 | -| --------------------------------------------------- | ------------------------- | --------------------------------- | ------- | -| JAG 夏合宿 2026 Day1(JAG) | `JAGSummer2026-day1` | 3684〜3695 | AOJ | -| JAG 夏合宿 2026 Day2(有志)(AMATSUKAZE2025) | `JAGSummer2026-day2` | 3696〜3707 | AOJ | -| JAG 夏合宿 2026 Day3(JAG) | `JAGSummer2026-day3` | 3708〜3719 | AOJ | -| JAG 夏合宿 2025 Day1(有志) | `JAGSummer2025-day1` | 3648〜3659 | AOJ | -| JAG 夏合宿 2025 Day2(JAG) | `JAGSummer2025-day2` | 3660〜3671 | AOJ | -| JAG 夏合宿 2025 Day3(有志) | `JAGSummer2025-day3` | 3672〜3683 | AOJ | -| JAG 夏合宿 2018 Day2(有志)(in AtCoder) | `jag2018summer-day2` | `jag2018summer_day2_a`〜`k` | AtCoder | -| JAG 夏合宿 2017 Day1(有志)(in AtCoder) | `jag2017summer-day1` | `jag2017summer_day1_a`〜`k` | AtCoder | -| JAG 夏合宿 2015 Day3(過去問)(in AtCoder) | `jag2015summer-day3` | `icpc2015summer_day3_a`〜`j` | AtCoder | -| JAG 夏合宿 2013 Day2 WarmUp(過去問)(in AtCoder) | `jag2013summer-warmingup` | `icpc2013summer_warmingUp_a`〜`j` | AtCoder | - -計 114 問(AOJ 72 は各 12 問、AtCoder 42)。いずれも「AOJ 未収録」の表記は無く、受け取った問題名はスプレッドシートと記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で、記号は基本ルールと一致する。 - -AtCoder Problems の `contests.json` には、ほかに `JAG2013Spring` / `JAG2014Spring` もある。AOJ の `JAGSpring2013` / `JAGSpring2014` と同じコンテストなので対象外とする。 - -## 表示順(76 コンテスト) - -PR2.5 の配列(`aoj_jag_regional_entries.ts`)の元。スプレッドシートの行順そのまま(上が新しい)。括弧書きは plan.md の決定事項に従う(「過去問」「in AtCoder」「AMATSUKAZE2025」は書かない)。「DB」列の「無し」は PR2.1 で入るもの、「補正」は上の 8 コンテスト。 - -| # | 行 | contest_id | 括弧書き | DB | -| --- | ---- | ------------------------------------ | ----------------------- | ---- | -| 1 | 2 | `JAGSummer2026-day1` | JAG | 無し | -| 2 | 15 | `JAGSummer2026-day2` | 有志 | 無し | -| 3 | 28 | `JAGSummer2026-day3` | JAG | 無し | -| 4 | 51 | `JAGSummer2025-day1` | 有志 | 無し | -| 5 | 64 | `JAGSummer2025-day2` | JAG | 無し | -| 6 | 77 | `JAGSummer2025-day3` | 有志 | 無し | -| 7 | 100 | `JAGSummer2024-day2` | 有志 | | -| 8 | 115 | `JAGSummer2024-day3` | JAG | 補正 | -| 9 | 137 | `JAGSummer2023-day2` | 有志 | | -| 10 | 150 | `JAGSummer2023-day3` | JAG | | -| 11 | 171 | `JAGRegional2022` | JAG | | -| 12 | 193 | `JAGRegional2021` | JAG | | -| 13 | 214 | `JAGRegional2020` | JAG | | -| 14 | 235 | `JAGSummer2019-day1` | JAG | | -| 15 | 247 | `JAGSummer2019-day2` | 有志 | | -| 16 | 258 | `JAGSummer2019-day3` | 有志 | | -| 17 | 278 | `jag2018summer-day2`(AtCoder) | 有志 | 無し | -| 18 | 290 | `JAGSummer2018-day3` | JAG | | -| 19 | 311 | `JAGRegional2017` | JAG | | -| 20 | 323 | `jag2017summer-day1`(AtCoder) | 有志 | 無し | -| 21 | 335 | `JAGSummer2017-day3` | JAG | | -| 22 | 356 | `JAGRegional2016` | in JAG 夏合宿 2016 Day3 | | -| 23 | 384 | `JAGRegional2015` | JAG | | -| 24 | 396 | `JAGSummer2015-day2` | 有志 | | -| 25 | 408 | `jag2015summer-day3`(AtCoder) | なし | 無し | -| 26 | 419 | `JAGSummer2015-day4` | JAG | | -| 27 | 438 | `JAGSpring2015` | JAG | | -| 28 | 451 | `JAGSummer2014-day2` | 有志 | | -| 29 | 462 | `JAGRegional2014` | in JAG 夏合宿 2014 Day3 | | -| 30 | 474 | `JAGSummer2014-day4` | 有志 | | -| 31 | 493 | `JAGSpring2014` | JAG | | -| 32 | 504 | `JAGRegional2013` | JAG | 補正 | -| 33 | 515 | `jag2013summer-warmingup`(AtCoder) | なし | 無し | -| 34 | 526 | `JAGSummer2013-day2` | 有志 | | -| 35 | 534 | `JAGSummer2013-day3` | 有志 | | -| 36 | 547 | `JAGSummer2013-day4` | JAG | | -| 37 | 566 | `JAGSpring2013` | JAG | | -| 38 | 577 | `JAGRegional2012` | JAG | | -| 39 | 588 | `JAGSummer2012-day2` | 有志 | | -| 40 | 599 | `JAGSummer2012-day3A` | 有志 | | -| 41 | 606 | `JAGSummer2012-day3B` | 有志 | | -| 42 | 614 | `JAGSummer2012-day4` | JAG | | -| 43 | 633 | `JAGSpring2012` | JAG | | -| 44 | 644 | `JAGWinter2011` | JAG | | -| 45 | 655 | `JAGRegional2011` | JAG | | -| 46 | 674 | `JAGSummer2011-day2` | 有志 | | -| 47 | 685 | `JAGSummer2011-day3` | JAG | | -| 48 | 696 | `JAGSummer2011-day4` | 有志 | | -| 49 | 707 | `JAGWinter2010-day3` | 有志 | | -| 50 | 718 | `JAGWinter2010-day4` | JAG | | -| 51 | 729 | `JAGRegional2010` | JAG | | -| 52 | 740 | `JAGSummer2010-day2` | 有志 | | -| 53 | 751 | `JAGSummer2010-day3` | 有志 | | -| 54 | 762 | `JAGSummer2010-day4` | JAG | | -| 55 | 781 | `JAGWinter2009` | JAG | | -| 56 | 792 | `JAGRegional2009` | JAG | | -| 57 | 803 | `JAGSummer2009-day2` | JAG | | -| 58 | 814 | `JAGSummer2009-day3` | JAG | | -| 59 | 832 | `JAGWinter2008-day2` | JAG | | -| 60 | 844 | `JAGWinter2008-day3` | JAG | | -| 61 | 856 | `JAGRegional2008` | JAG | | -| 62 | 867 | `JAGSummer2008-day2` | JAG | | -| 63 | 878 | `JAGSummer2008-day4` | JAG | | -| 64 | 894 | `JAGWinter2007-day2` | JAG | | -| 65 | 905 | `JAGWinter2007-day3` | JAG | 補正 | -| 66 | 916 | `JAGRegional2007` | JAG | | -| 67 | 926 | `JAGSummer2007-day1` | JAG | | -| 68 | 932 | `JAGSummer2007-day2` | JAG | | -| 69 | 942 | `JAGSummer2007-day3` | JAG | | -| 70 | 957 | `JAGWinter2006-day2` | JAG | 補正 | -| 71 | 967 | `JAGWinter2006-day3` | JAG | 補正 | -| 72 | 978 | `JAGRegional2006` | JAG | | -| 73 | 988 | `JAGSummer2006-day1` | JAG | 補正 | -| 74 | 992 | `JAGSummer2006-day2` | JAG | | -| 75 | 1002 | `JAGSummer2006-day3` | JAG | 補正 | -| 76 | 1017 | `JAGRegional2005` | JAG | 補正 | - -内訳: 合宿系 50、模擬地区 16、DB に無いもの 10(AOJ 6、AtCoder 4)。 - -### 年の中での並び - -contest_id から並びを導けない例(スプレッドシートの上から順)。冬合宿・冬コンテストは、年の数字が同じ夏合宿・模擬地区より上にある。 - -- 2014: 夏合宿 Day2 → 模擬地区(夏合宿の Day3 として開催)→ 夏合宿 Day4 → 春コンテスト 2014 -- 2012: 模擬地区 2012 → 夏合宿 2012 → 春コンテスト 2012 → 冬コンテスト 2011 → 模擬地区 2011 → 夏合宿 2011 -- 2010: 冬合宿 2010 → 模擬地区 2010 → 夏合宿 2010 - -## 名称の違い - -| スプレッドシート | 現行のコード(`JAG_TRANSLATIONS`) | 対象 | -| ---------------- | ---------------------------------- | ------------------------------------------------- | -| 春コンテスト | 春合宿 | `JAGSpring2012`〜`2015` | -| 冬コンテスト | 冬合宿 | `JAGWinter2009` / `JAGWinter2011`(日の無いもの) | -| 模擬アジア地区 | 模擬地区 | `JAGRegional*` | - -テーブル名は現行のコードに合わせる。出典欄と同じ名称になり、違うのは括弧書きの有無だけになる。 - ---- - -# 2. 実測データ - -## 対象規模 - -`prisma/tasks.ts` の集計(2026-10-21)と、本番だけにある JAG 模擬地区。 - -| 種別 | コンテスト | 問題数 | 所在 | PR | -| ------------ | ---------- | ------ | ----------- | --- | -| ICPCPrelim | 29 | 197 | seed と本番 | PR1 | -| ICPCRegional | 27 | 266 | seed と本番 | PR1 | -| JAGPrelim | 23 | 169 | seed と本番 | PR1 | -| JAGSummer | 36 | 338 | seed と本番 | PR2 | -| JAGWinter | 10 | 87 | seed と本番 | PR2 | -| JAGSpring | 4 | 42 | seed と本番 | PR2 | -| JAGRegional | 16 | 158 | 本番のみ | PR2 | - -PR1 / PR2 の合計は 1,257 問 / 145 コンテスト。PR1 の前は、seed の 1,099 問すべてで `problem_index` == `task_id`(AOJ 問題 ID)だった。 - -## 本番の件数(2026-10-21) - -本番で `contest_type` ごとに集計した結果。seed と一致するのは `AOJ_ICPC` だけだった。 - -| contest_type | 本番(コンテスト / 問題) | seed(コンテスト / 問題) | -| ---------------- | ------------------------- | ------------------------- | -| `AOJ_ICPC` | 56 / 463 | 56 / 463 | -| `AOJ_JAG` | 89 / 794 | 73 / 636 | -| `AOJ_UNIVERSITY` | 27 / 30 | 27 / 29 | -| `AOJ_COURSES` | 8 / 247 | 0 / 0 | -| `AOJ_PCK` | 1 / 12 | 0 / 0 | - -`AOJ_JAG` の差(16 コンテスト / 158 問)はすべて JAG 模擬地区(管理画面から取り込める。seed には 0 件)。接頭辞ごとに分けると、ほかはすべて本番と seed で一致した。 - -AtCoder 側(`AOJ_*` 以外)で title が `${task_table_index}. ` で始まらない問題は、本番でも `abs` の practice_1 の 1 件だけだった。 - -## PR2 の範囲の本番と staging(2026-10-24) - -本番と staging で範囲の状態を集計した結果(SQL は `git show 7c68cef6:docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2.md` の「確かめ方」)。 - -| 範囲 | 本番 | staging | index が問題 ID のまま | title が問題 ID 付き | -| -------- | -------- | ------------------------------- | ---------------------- | -------------------- | -| Regional | 16 / 158 | 16 / 158(取り込み前は 2 / 16) | 全行 | 0 行 | -| Spring | 4 / 42 | 4 / 42 | 全行 | 全行 | -| Summer | 36 / 338 | 36 / 338 | 全行 | 全行 | -| Winter | 10 / 87 | 10 / 87 | 全行 | 全行 | - -- staging には当初、模擬地区が 2 コンテスト / 16 問しか無かった。PR1.5(#4219)の後に staging で取り込み、本番と同じ 16 / 158 にそろえた(2026-10-24) -- 本番の模擬地区 158 問(本番から取り出したもの)は、AOJ の一覧のスクリーンショットと問題名・問題 ID がすべて一致した。grade が付いているのは 6 問 -- 本番の 3355 の title の末尾にタブがある(`Most distant point from the stations\t`)。前後に空白のある title はほかにもある(下記) - -## 前後に空白のある title - -本番で `title <> btrim(title, E' \t\r\n')` の行を数えると 46 件あった(2026-10-24)。 - -| 範囲 | 件数 | 例 | -| --------------------------------------- | ---- | -------------------------------------------------------- | -| JAG 模擬国内(3202 / 3250 / 3299) | 3 | `"Chains of Explosions "`(末尾の空白) | -| JAG 模擬地区(3355) | 1 | `"Most distant point from the stations\t"`(末尾のタブ) | -| AtCoder(ABC / AGC / ARC / JOI / TTPC) | 42 | `"A. Weather Prediction "`(末尾の空白) | - -- **出どころは上流のデータ**。AOJ の API が返す名前そのものに空白がある(2026-05 に記録した fixture の `jag_prelim` / `jag_regional` にも同じ空白がある)。AtCoder Problems も同様 -- **再発はしない**。取り込みが title を `trim()` するようになった(2026-05-18、`0e1ec4e7`)。46 件はそれより前に入った行。seed の値は trim 済み -- **実害は無い**。HTML の表示では末尾の空白が潰れる。title を比べるコードは `removeTaskIndexFromTitle` の `startsWith`(先頭の `A. ` を見るだけ)しか無く、完全一致で比べる箇所は無い。検索は部分一致なので、空白で終わる検索語でなければ影響しない -- **起こりうる害**: 将来 title の完全一致で照合する処理(#4018 のコンテスト情報の同期など)を書くと、この 46 件だけが食い違う。CSV の `id` は `sha256(contest_id + title)` だが、既存行の `id` は再計算されないので関係ない - -PR2 では直さず、残タスクとした([plan.md](./plan.md#スコープ外))。 - -## 欠落は機械的に検出できない - -AOJ 問題 ID の連番性を調べると、seed の 129 コンテスト中 128 が連番。**だが欠落が無い証拠にならない。** AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのにコンテスト一覧(取り込み元)から外れている場合も同じである。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に問題 1123 として存在するが一覧に無く、取り込まれていない。2002 年の A〜D は 1119〜1122 で連番のままであり、1123 の次の 1124 は 2003 年の A である。 - -連番でない 1 コンテストは `JAGSummer2024-day3`(3643 が無い)で、スプレッドシートの G(Interactive String Guessing)に当たる。スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 はあり、コンテスト一覧から外れているだけだった。 - -データから欠落を示唆するのは**問題数の異常**の方だが、それも決め手にならない: - -| コンテスト | 問題数 | 同種の中央値 | スプレッドシートとの照合 | -| -------------------------- | ------ | ------------ | ------------------------------------------------------------- | -| `JAGSummer2006-day1` | 1 | 10 | 公式は 3 問。A・C が AOJ 未収録 | -| `JAGWinter2006-day2` | 2 | 10 | 公式は 9 問。7 問が AOJ 未収録 | -| `JAGWinter2006-day3` | 4 | 10 | 公式は 10 問。6 問が AOJ 未収録 | -| `JAGSummer2007-day1` | 5 | 10 | 公式も 5 問(欠落なし) | -| `ICPCPrelim1998/2000/2002` | 各 4 | 8 | 1998 / 2000 は公式も 4 問。2002 は E が一覧に無い(AOJ 1123) | - -## `title` の持ち方が AOJ 内で割れている - -| 範囲 | 件数 | `title` の形 | 例 | -| ------------------------------------------ | ----- | ------------------------------- | --------------------------------- | -| ICPC国内 / アジア / JAG模擬国内(**PR1**) | 632 | 素 | `'Peculiar Protocol'` | -| JAG 合宿系(**PR2**) | 467 | **数値ID のプレフィックス付き** | `'2389. Asynchronous Exceptions'` | -| JAG 模擬地区(**PR2**、本番のみ) | 158 | 素 | `'Restriction Enzyme'` | -| AtCoder 等(対象外) | 1,366 | index のプレフィックス付き | `'EX8. たくさんのA足すB問題'` | - -**プレフィックスは合宿系の 467 件すべてで `task_id` と完全に一致していた(不一致 0 件)。** したがって除去も復元も機械的に安全に行える。 - -模擬地区は管理画面の取り込みで本番に入っており、取り込みは導入時(2024-10、`7dcc5e4c`)から `problem.name` をそのまま title にしている。範囲の集計でもプレフィックスは 0 行だった(本番・staging)。 - -退行の機序(title を直さずに index だけ記号にした場合): - -``` -現在: index='2389', title='2389. Asynchronous Exceptions' - → 先頭の '2389. ' を除去 → 'Asynchronous Exceptions' 正常 - -PR2 後: index='A', title='2389. Asynchronous Exceptions' - → 先頭の 'A. ' を除去 → 空振り - → '2389. Asynchronous Exceptions' が露出 退行 -``` - -## 復元可能性の実測 - -| 対象 | 件数 | 復元 | -| ------------------------ | ----- | ---------------------------------------------------------------------------------------- | -| AOJ ICPC/JAG(更新対象) | 1,099 | **無条件に可能**。`task_table_index == task_id` が例外ゼロで成立(seed、PR1 の前) | -| その他(AtCoder 等) | 1,367 | **条件付き**。84.2%(1,151 件)は `task_id` のサフィックスから導出できるが、216 件は例外 | - -本番にある模擬地区 158 件も、範囲の集計で全行 `task_table_index == task_id` だった(2026-10-24)。 - -導出できない 216 件の内訳: `abc` 41(`abc318_h` に対し index は `Ex`)、`typical` 32、 `APG` 27(`APG4bPython_co` に対し index は `EX8`)、`arc` 27、`tdpc` 20、 `math-and-algorithm` 15、ほか(`task_id` にサフィックスが無い AOJ 大学系 29 件を含む)。 - -`abc*_h → Ex` は規則として書けるため理論上は 90% 超まで導出できるが、 **事故対応の手段としては成立しない**。理由は 2 つ。 - -1. 「84% は規則で戻せるが 16% は個別対応」という状態は、復旧手順として使えない -2. `task_table_index` を上書きした後では、**どの行が元から `A` だったのかを区別できない** - -## 既存 DB は自動更新されない - -- seed: `prisma/seed.ts` で `findUnique(task_id)` → 既存なら **skip** -- 取り込み: `src/lib/services/tasks.ts` の `createTask` で `isExistsTask(task_id)` → 既存なら **silent return**。`classifyContest` が `null` を返す contest_id も黙って捨てる -- `updateTask` は `grade` しか触らない - -## 一意性は壊れない - -`prisma/schema.prisma` の Task で unique なのは 2 つのみ。 - -| フィールド | 由来 | -| ------------------- | ------------------------------ | -| `id` (@id @unique) | `sha256(contest_id + task.id)` | -| `task_id` (@unique) | `task.id` をそのまま | - -どちらも入力は contest_id と AOJ 問題 ID のみ。`problem_index` は混ざらない。FK も全て `task_id` 参照(`TaskTag` / `TaskAnswer` / `WorkBookTask` / `VoteGrade` / `VotedGradeCounter` / `VotedGradeStatistics`)。ハッシュの `id` を FK にしている箇所は無い。 - -**性質の変化**: `task_table_index` は「全 DB でユニーク」から**「コンテスト内でのみユニーク」**になる。unique 制約は無く、コンテスト横断で index をキーにしている箇所も無い(`sortAojHeaderIds` は単一コンテスト内で閉じる)ため問題ない。 - ---- - -# 3. SQL の解説 - -## SQL の読み方 - -**大きなクエリは内側から読み、各段階で「これは何の表を作るのか」を問う。** `WITH` 形式ならその順序がそのまま上から下になる。 - -`letters` が作るのは **`task_id` と `letter` の 2 列だけの一時的な表**。 - -| task_id | letter | -| ------- | ----------------------------------------- | -| 1100 | A | -| 1101 | B | -| 1102 | C | -| 1103 | D | -| 1672 | **A** ← コンテストが変わり番号が 1 に戻る | -| 1673 | B | - -これが分かれば `UPDATE` 側は単純で、「`task` と `letters` を `task_id` で突き合わせ、一致した行に `letter` を書き込む」だけ。 - -| 句 | 意味 | -| -------------------------------- | -------------------------------------------------------------------------- | -| `PARTITION BY contest_id` | **コンテストごとにグループ分けし、番号を 1 から振り直す** | -| `ORDER BY task_id::int` | 各グループ内で問題 ID の数値昇順に並べる | -| `row_number()` | 並べた順に 1, 2, 3… を返す(**1 始まり**、`bigint` なので `::int` が要る) | -| `chr(64 + n)` | 文字コード変換。`chr(65)` = `A` なので `n=1 → A`、`n=2 → B` | -| `contest_id ~ '^JAG(Summer\|…)'` | `AOJ_JAG` には PR1 で済んだ模擬国内も含まれるため、接頭辞で範囲を絞る | - -`PARTITION BY` が無いと対象の全問題に通し番号が振られ、2 つ目以降のコンテストが `Z` を超えて壊れる。 - -> **SQL は書かれた順に評価されない。** 論理的な処理順序は `FROM`(サブクエリと別名の確定)→ `WHERE` → `SELECT` / `SET`。このため別名を定義より前に参照しているように見えても正しい(`SELECT u.name FROM users AS u` と同じ理屈)。 - -実データでの確認: `ICPCPrelim2024`(9 問)は `1672 → A`, `1673 → B`, … `1680 → I`。 `ICPCPrelim1998` は 4 問なので `A`〜`D`。 - -## 計算量 — O(N²) にはならない - -- `WHERE` の条件は **join ではなくフィルタ**。1 行あたり O(1) で評価され、行数を減らす方向にしか働かない -- join 側も、`task_id` に UNIQUE インデックスがある(`prisma/migrations/0_init/migration.sql` の `task_task_id_key`)ため、Nested Loop + Index Scan なら O(M log N)、Hash Join なら O(N + M)。二乗にはならない -- 支配項はウィンドウ関数のソート O(K log K)(K = PR2 で 625)。実行時間はミリ秒台 - -**推測ではなく実行計画で確認すること。** PR1 では `task` の Seq Scan 2 回 + Hash Join だった(`contest_type` に索引が無いため想定どおり)。 - ---- - -# 4. 検討の経緯(採らなかった案) - -**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。PR3 の採らなかった案は [plan.md](./plan.md#採らなかった案) にまとめた。 - -## `title` に記号を入れる案を採らない理由 - -`title` を `'A. Peculiar Protocol'` に揃えれば AtCoder 側 1,366 件と形式が揃うが、採らない。 - -- **同じ情報を 2 列に持つとドリフトする。** 欠落判断の誤りを修正するとき(修正は起こる前提)、両方を更新しないと不整合になる -- **G2 に逆行する。** title にプレフィックスを埋め込む設計は、表示側に文字列の除去を要求する。除去したかった種類の変換そのもの -- **除去は脆い。** 合宿系の title の退行がその実例 - -「入れない」を選んでも AtCoder と AOJ で title の意味は揃わないが、 **どちらを選んでも不整合は残る**(AtCoder 側を触るのはスコープ外)。AOJ を「素の title」に寄せ、セルで `A. ` を付ける方が、PR1 の設計(`buildTitleWithTaskIndex`)と整合する。 - -## 管理画面案を保留にした理由 - -「管理画面から SvelteKit の action で更新する」案には実質的な利点がある。 - -- **再実行できる。** マイグレーションは適用後 immutable なので、欠落判断の誤りが後から見つかると新しいマイグレーションを積むことになる -- 欠落判断は人間が行う以上、**修正は起こる前提**であるべき - -一方で、この案は**問題記号の入力元が定義できないと成立しない**。手入力なら 1,000 回超の手作業に戻り、難所 1 で否定したものに戻る。コード上の対応表を読むなら、実質「対応表を DB に同期するボタン」になる。 - -さらに、素直に実装すると **N+1 になりやすい**。既存の `createTask` がまさにその形で(`src/routes/(admin)/tasks/+page.server.ts` の `Promise.all` + check-then-act)、トランザクションが無いため途中で失敗すると部分適用が残る。採るなら `updateMany` か `$executeRaw` で一括処理する設計が前提になる。 - -**再検討の結果(2026-10-24)**: PR2 の照合で補正が要るのは 8 コンテストと分かり、マイグレーション 1 本で足りると判断した。補正の誤りが繰り返し見つかるようなら再検討する。 - ---- - -# 5. Task テーブルへの書き込み経路の全数調査 - -リポジトリ全体(`src` / `prisma` / `e2e`)を確認した結果。移行 UPDATE の安全性の根拠。 - -## 書き込み経路は 3 つだけ、削除経路はゼロ - -| 経路 | 実装 | 書き込むフィールド | -| -------------------- | ------------------------------------------- | ------------------------------------------------------------------------------- | -| 管理画面の取り込み | `src/lib/services/tasks.ts` の `createTask` | `id` / `task_id` / `contest_id` / `contest_type` / `task_table_index` / `title` | -| 管理画面の難易度更新 | `src/lib/services/tasks.ts` の `updateTask` | `grade` のみ | -| seed | `prisma/seed.ts` | 上記 + `grade`(`id` は未指定) | - -- `updateTask` の呼び出し元は 2 つ(`src/routes/(admin)/tasks/[task_id]/+page.server.ts`、 `src/routes/(admin)/tasks/grade/+page.server.ts`)。いずれも `grade` のみ -- `task.delete` / `deleteMany` / `upsert` / `createMany` は**リポジトリ全体に存在しない** -- 生 SQL(`$executeRaw` / `$queryRaw`)による Task への書き込みもなし -- 他モデルからのネストした `task: { create: ... }` もなし - -⇒ **一意フィールドを UPDATE するコードは構造的に存在しない。** リポジトリの外では、staging / production に Supabase の CSV で行を入れることがある(既存の合宿系、PR2.1)。 - -## `id` の採番規則が経路ごとに違う - -- 取り込み: `sha256(contest_id + task.id)`(16 進)— 決定的 -- Supabase の CSV(既存の合宿系、PR2.1): `sha256(contest_id + title)`(16 進)。メンテナが別プロジェクトのスクリプトで作る(2026-10-24 にメンテナが説明) -- seed: `id` 未指定のため fabbrica の自動採番で連番文字列 - -一意性は破れないが「`Task.id` は `task_id` から決まるハッシュ」という前提は**取り込み以外では成立しない**。ただし **`Task.id` を再計算して引く箇所はゼロ**で、全ルックアップは `task_id` 経由。現状の実害はないが、将来 `Task.id` に決定性を期待するコードを書くと静かに壊れる。 - -## check-then-act の競合 - -`createTask` は `isExistsTask`(`findMany`)→ `create` の check-then-act で、 `src/routes/(admin)/tasks/+page.server.ts` がこれを `Promise.all` で並列実行している。 - -同一 `task_id` が 1 回の取り込み JSON に 2 件含まれると、両方がチェックを通過して 2 件目が P2002 で落ちる。`Promise.all` 全体が reject して 500 を返すが、 **トランザクションが無いため先に成功した分はロールバックされない**(部分適用)。 - -`prisma/seed.ts` も同じ形で PQueue concurrency 3 で並列実行しており同種のレースがある。 - -一意性は DB 制約が守るので壊れはしないが、エラー時の挙動が中途半端。 `docs/guides/agent-rules/prisma-db.md` の「Idempotent Writes」に該当し、本来は `createMany({ skipDuplicates: true })` か `upsert` が解。**本件とは独立の既存課題**(O5)。 - -## 他の sha256 決定的 ID は Task から独立 - -`src/lib/services/answers.ts` と `src/features/votes/services/vote_grade.ts` はいずれも入力が `Task.id` ではなく `task_id`。`vote_grade.ts` は全て `upsert` で `where` に複合ユニークを使うため id 衝突の経路もない。 - -## 削除経路がないことの帰結 - -Task を削除するコードが存在しないため、関連テーブルが孤児 FK を持つ事故は現状起こらない。将来 Task 削除機能を追加する場合(PR3 のダミーの扱いなど)、6 つの関連のうち `TaskTag` / `TaskAnswer` / `WorkBookTask` は `onDelete` 未指定なので要注意(投票系 3 つは `Cascade`)。 - ---- - -# 6. 調査中の誤りと訂正(自戒) - -1. **対象を 632 問と集計した** — Provider が存在する 3 種のみを数え、seed にデータがある合宿系 467 問を落としていた。正しくは 1,099 問。 _教訓: 「実装がある範囲」と「データがある範囲」は一致しない。集計条件は後者で取る。_ - -2. **「問題 ID が連番だから欠落なし、機械生成できる」と結論した** — 欠落問題は ID も採番されないため、連番性は欠落の有無について何も語らない。検証が問いに答えていなかった。 _教訓: 「観測できる範囲に異常が無い」ことを「異常が無い」と読み替えない。欠落は定義上、観測できない側にある。_ - -3. **抽出スクリプトのバグを、データの欠落として提示しかけた** — `prisma/tasks.ts` からタスクを抜き出す正規表現がシングルクォート前提だったため、 `name: "A Bug That's Not a Pill Bug"`(アポストロフィを含むためダブルクォート)を取りこぼし、`ICPCPrelim2024` が 8 問に見えた。実際は 9 問で、欠番は存在しなかった。 _教訓: 抽出スクリプトを書いたら、まず総数が既知の値と一致するかを確認する。1 と同じ「観測範囲を実際の範囲と取り違える」失敗であり、本件では欠落の誤検出に直結する。_ - -4. **移行 SQL を seed 由来の対応表から生成する案を出した** — production のタスクは管理画面の取り込み経由で入っており seed と一致する保証がないため、production にしか無い行が取り残される。しかも `UPDATE` は該当行が無くてもエラーにならない。 _教訓: 2 と同じ型。「seed で見えている範囲」を「production の実態」と取り違えた。_ - -5. **AtCoder 系の復元を「不可能」と断定した** — 実際は `task_id` のサフィックスから 83.4% が導出できる。ただし事故対応としては成立しないという結論自体は変わらない。 _教訓: 断定する前に測る。結論が同じでも、根拠が誤っていると次の判断で誤用される。_ - -6. **PR を分けた後も、移行 SQL の範囲を 1,099 件のままにしていた**(2026-10-21 に発見)— `contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` は合宿系も含むため、PR1 で 467 件を余計に更新するところだった。 _教訓: 範囲を分割したら、範囲を表す条件をすべて追従させる。分類(`contest_type`)と分割の単位が一致しているとは限らない。_ - -7. **欠落した問題を「AOJ に存在しない(ID も無い)」と決めつけていた**(2026-10-21 に発見)— ICPC 国内予選 2002 の E は AOJ に問題 1123 として存在し、コンテスト一覧(取り込み元)から外れているだけだった。PR3 でダミーを作るという前提は、この問題には当てはまらない。 _教訓: 「一覧に無い」と「存在しない」は別。データの取得元の範囲を、対象が実在する範囲と取り違えない(1・4 と同じ型)。_ - ---- - -# 7. PR3: 欠けている問題 - -[plan.md の PR3 の節](./plan.md#pr3-欠落問題の補完) の根拠。確認はすべて 2026-10-28 時点(メンテナのスクリーンショットと AOJ の API、リポジトリのコード)。採らなかった案は [plan.md](./plan.md#採らなかった案) に 1 か所にまとめた。 - -## 調べ方と結果 - -公式の問題なのに seed に無いものを、AOJ の問題一覧 API で問題名から探した(部分一致)。対象は、スプレッドシートで「AOJ 未収録」とある 22 問、スプレッドシートのリンク先の title が AOJ で見つからなかった 3 問(JAG 模擬地区 2013 の A〜C)、PR1 で見つかった 1 問(ICPC 国内予選 2002 の E)。 - -結果は 26 問で、AOJ にあるものが 5 問、AOJ に無いものが 21 問。 - -- スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない。書いた後で AOJ に追加された問題がある(3643) -- AOJ のコンテスト一覧(管理画面の取り込み元)に無いことも、AOJ に無いことの保証にならない。問題一覧 API には載っている問題がある(下の 5 問) -- AOJ は過去の問題を後から追加するので、この結果は確認した時点のもの - -## AOJ にある 5 問 - -どれも AOJ のコンテスト一覧には無いが、問題一覧 API に同じ問題名で載っている。 - -| 問題 | AOJ の問題 ID | メモ | -| --------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| ICPC 国内予選 2002 の E(Enclosing Circles) | 1123 | A〜D は 1119〜1122 | -| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | 3643 | 2026-10-10 に AOJ に追加された。`available` が 4(ほかの問題は 1)で、提出が 0。公開前とみられるが、`available` の値の意味は未確認 | -| JAG 模擬地区 2013 の A〜C(Broken Audio Signal / Restore Calculation / SIRO Challenge) | 2565〜2567 | D〜J(2568〜2574)と連番 | - -## AOJ に無い 21 問 - -表記ゆれのありそうな語(`Wireing`、`Shortest`、`Geometry`)も部分一致で探したが、見つからなかった。 - -| コンテスト | 記号と問題名 | jag-icpc.org の年度ページ | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | [2006/Practice/夏合宿][jag-2006-summer] | -| JAG 夏合宿 2006 Day3 | A Rolling Cubes | [2006/Practice/夏合宿][jag-2006-summer] | -| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | [2006/Practice/冬合宿][jag-2006-winter] | -| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | [2006/Practice/冬合宿][jag-2006-winter] | -| JAG 冬合宿 2007 Day3 | E Networking Company | [2007/Practice/冬合宿][jag-2007-winter] | -| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | [2005/Practice/模擬地区予選][jag-2005-regional] | -| JAG 模擬地区 2008 | J Colony Maintenance | [2008/Practice/模擬地区予選][jag-2008-regional] | - -[jag-2006-summer]: https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF -[jag-2006-winter]: https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF -[jag-2007-winter]: https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF -[jag-2005-regional]: https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 -[jag-2008-regional]: https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8 - -- 合宿系 17 問、模擬地区 4 問。7 コンテストはすべて 2005〜2008 年で、冬合宿 2006 の Day2 / Day3 に 13 問が集中する -- 問題名は jag-icpc.org の表記とすべて一致した。19 問は年度ページの問題一覧で、2 問(冬合宿 2007 Day3 の E、模擬地区 2008 の J)は問題文の PDF の表紙で確かめた。seed の title にはこの表記を使う -- `Wireing Assistant` は原文どおりの綴り。`The "Shortest" Pair of Paths` の引用符は半角 - -## jag-icpc.org - -JAG のサイトで、スプレッドシートの情報源の一つ。URL の形式(`?plugin=attach&refer=...&openfile=...`)から PukiWiki とみられる。 - -- 構成は `年度/Practice/大会区分/問題文とデータセット/`(その下に問題文の PDF と注釈)。`問題文とデータセット` と同じ階層に、講評などのページもある -- 21 問の 7 コンテストは、5 つの年度ページにまとまる。どのページにも問題文へのリンクがある -- 添付ファイル名は揃っていない(2006 年夏合宿は `day1.pdf` など日ごと、2008 年模擬地区予選は `problems.pdf`) -- 年度ページの問題一覧は手書きで、誤記が混じる(例: PDF の表紙と AOJ が `Flame of Nucleus` の問題を、一覧は `Frame of Nucleus` と書いている)。表記が食い違ったら、問題文の PDF の表紙で判断する - -## 記号がずれないか - -PR3 で記号(`task_table_index`)がずれる心配は、事実上ない。 - -- **今ある記号は変わらない**。各行に保存された値で、PR2 で補正済み(本番・staging とも反映済み)。PR3 は行を足すだけで、書き換えない。画面も保存された値を使い、問題 ID から計算し直さない。PR1 / PR2 のマイグレーションが再び流れることもない([影響範囲](#影響範囲)) -- **足す 26 行の記号は、2 つ以上の根拠がそろう**。ダミーの 21 問は、スプレッドシートの位置と jag-icpc.org(年度ページの問題一覧で 5 コンテスト、問題文の PDF の表紙で 2 コンテスト)。AOJ にある 5 問は、スプレッドシートの位置と AOJ の ID の並び(1119〜1123 の E、3642 と 3644 の間の G、2565〜2574 の A〜C) -- **空いている位置にちょうど収まる**(下表)。今ある記号も、年度ページの問題一覧と一致した(夏合宿 2006 Day1 / Day3、冬合宿 2006 Day2 / Day3、模擬地区 2005) -- **seed はテストが固定する**。`seed_problem_letters.test.ts` が記号を 1 問ずつ確かめる([plan.md の PR3 の節](./plan.md#pr3-欠落問題の補完)) - -残るのは人の作業の誤りで、どちらも確かめる手順がある: - -| リスク | 起きる条件 | 守り | -| -------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| 本番の CSV の打ち間違い | メンテナが作る CSV の記号を間違える | 投入後の SQL で、記号の重なりと 10 コンテストの記号を確かめる([plan.md](./plan.md#staging--本番への投入メンテナ))。見つかっても UPDATE 1 本で直る | -| 管理画面の取り込みで、ずれた記号が付く | 将来 AOJ が欠けた問題を足し、それを管理画面から取り込む(PR3 の前からある危険) | 取り込まず、ダミーの `task_id` を UPDATE する([手順](#aoj-に後から追加されたとき)) | - -ダミーで足す記号: - -| コンテスト | 今ある記号 | ダミーで足す記号 | 合わせると | -| ------------------ | ---------------- | ------------------- | ---------- | -| JAGSummer2006-day1 | B | A, C | A〜C | -| JAGSummer2006-day3 | B〜G | A | A〜G | -| JAGWinter2006-day2 | D, F | A, B, C, E, G, H, I | A〜I | -| JAGWinter2006-day3 | A, D, G, H | B, C, E, F, I, J | A〜J | -| JAGWinter2007-day3 | E 以外の A〜J | E | A〜J | -| JAGRegional2005 | A, B, D, E, G, I | C, F, H | A〜I | -| JAGRegional2008 | A〜I | J | A〜J | - -AOJ にある 5 問を足すと、ICPC 国内予選 2002 は A〜E、模擬地区 2013 は A〜J、夏合宿 2024 Day3 は A〜K になる。 - -## ダミーの `task_id` - -`NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字(`A`)のまま。 - -- 接頭辞が、コードが分岐に使う性質(採点の仕組みが無い)をそのまま表す。ID だけでダミーと分かり、判定は `startsWith('NoJudge-')` で足りる -- 数字でも小文字始まりでもないので、AOJ(数字)とも AtCoder(`abc300_a` など)とも構造上ぶつからない -- 記号を小文字にするのは、AtCoder の形式(`abc300_a`、`jag2018summer_day2_a`)に合わせるため -- `contest_id` と記号の組が一意なので、ID も一意になる -- 英数字・`-`・`_` だけなので、`/problems/{task_id}` にそのまま入る - -## リンク先 - -jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`)にする。 - -- 提出先が無いので、自分で採点するには問題文だけでなく、ジャッジデータと講評も要る。大会区分のページからは、どれにも下りられる -- 深いページに着地したユーザーは、上の階層にほぼ戻らない(PDF には戻る手段すら無い)。入口のページが、ユーザーの見つけられる範囲を決める -- 階層が浅いほど、下位ページ名や添付ファイル名の変更でリンクが切れる箇所も、URL 中の日本語も減る -- リンク先は map の値にすぎないので、後から深くするのは定数の差し替えで済む - -## リンクの出し方 - -`getTaskUrl()`(`src/lib/utils/task.ts`)の URL ジェネレータの並びに `NoJudgeGenerator` を足す。問題へのリンクはすべて `getTaskUrl()` を通るので、1 か所で全画面に効く。 - -```typescript -class NoJudgeGenerator implements UrlGenerator { - canHandle(_contestId: string, taskId: string): boolean { - return isNoJudgeTask(taskId); - } - - generateUrl(_contestId: string, taskId: string): string { - return NO_JUDGE_TASK_URLS.get(taskId) ?? ''; - } -} - -// NoJudge must precede AOJ: dummies share JAG contest_ids. -const urlGenerators: UrlGenerators = [ - new NoJudgeGenerator(), - new AojGenerator(), - new AtCoderGenerator(), -]; -``` - -- 既存の設計(ジェネレータを並べ、最初に `canHandle` が真になったものを使う)に沿う。`getTaskUrl()` の本体は変わらず、`task.ts` の TODO(Codeforces・yukicoder・BOJ)も同じ形で足せる -- `UrlGenerator.canHandle`(`src/lib/types/url.ts`)に `taskId` を足す。使うのは `task.ts` だけで、既存の 2 つの実装は引数が少ないままでも型を満たす -- **並び順に意味がある**。ダミーは JAG の `contest_id` を使うので、`NoJudgeGenerator` を `AojGenerator` より前に置かないと、AOJ 側が先に受け取る。コメントとテストで固定する -- 判定は出典のラベルと共通の `isNoJudgeTask()`。ダミーかどうかの基準が 1 つになる -- URL は問題ごとではなくページごとに決まる(21 問に対して 5 ページ)。map は `{ url, taskIds }` をページごとに並べて組み立て、各 URL を 1 回だけ書く -- URL はブラウザで開けたものを、パーセントエンコードのまま貼る(ソースは ASCII だけになる)。そのまま返すことを単体テストで固定し、二重エンコードや書き換えを防ぐ -- ダミーは DB、map はコードにあるので、漏れがあり得る。漏れたら空文字を返し、AOJ のジェネレータには回らない(本物らしく見えて開けない AOJ のリンクにはならない)。seed のダミーが全件 map にあること、map のキーがすべて seed にあることをテストで確かめる - -## 出典のラベル - -ダミーの出典は `(AOJ)` ではなく `(No Judge)` にする(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。 - -- 括弧の中は「どこで解けるか(どこで採点されるか)」を表す。採点の無い問題に `(AOJ)` と出すのは誤り -- `task_id` の接頭辞 `NoJudge-` と言葉がそろう -- 問題一覧や問題集では、アイコンを足さずに文字だけで、採点が無いことを伝えられる -- `addContestNameToTaskIndex()` は `contest_id` と記号しか受け取らない。ダミーは本物の AOJ の問題と同じ `contest_id` を使うので見分けられず、`taskId` を足す -- `taskId` は省略できない引数にする。型チェックが呼び出し元 6 か所の漏れをすべて見つける。どの呼び出し元も `task_id` を持っている - -## アイコンもツールチップも入れない理由 - -採点が無いことは、出典のラベルの `(No Judge)` で文字として伝える。コンテスト別の表のセルには出典のラベルが出ないので、表の見た目は変わらない。 - -- 表のセルは [難易度バッジ] [問題名(長いと省略)] [状態のメニュー] で、AOJ の表は 1 行に最大 6 列。問題名はもう省略されていて、アイコンを足すとさらに削られる。外部リンクのアイコンも、表では消している(`ExternalLinkWrapper` の `iconSize={0}`) -- ホバーで出る jag-icpc.org の URL だけでは気づきにくい。スマホにはホバーが無く、画面の隅の URL を見る人は少なく、ドメインが違っても提出先が無いことまでは読み取りにくい -- それでも、引っかかったときの損は小さい。リンクは新しいタブで開き、開けば提出フォームの無い Wiki だと分かる。解答状況はユーザーが自分で設定するので、ジャッジデータで確かめて AC にできる - -## 影響範囲 - -変更が要る機能は、問題へのリンク(`getTaskUrl()`)と出典のラベル(`addContestNameToTaskIndex()`)の 2 つ。ほかは Task のレコードを足せば動く。 - -| 機能・処理 | 何で決まるか | ダミーへの影響 | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| コンテスト別の表 | `contest_id` で絞り、`task_table_index` の順に列を並べて置く(`sortAojHeaderIds`、`generateTable`)。`task_id` は使わない | 変更なし。7 コンテストとも表の設定(`aoj_jag_regional_entries.ts`)がある | -| セルの表示名 | `task_table_index` を付けて「A. Monkey Business」と出す。title の先頭の「記号. 」を外してから付けるが、21 問の title はどれも「記号. 」で始まらない | 変更なし | -| 問題へのリンク | すべて `getTaskUrl()` を通る(呼び出し元 9 ファイル) | `NoJudgeGenerator` を足す | -| 出典のラベル | `addContestNameToTaskIndex(contestId, taskTableIndex)` が `contest_id` だけを見て、AOJ 系なら `(AOJ)` を付ける。呼び出し元は 6 ファイル(各 1 か所) | `taskId` を足し、ダミーは `(No Judge)` にする | -| 投票・タグ・問題集・回答 | `task_id` への外部キー(6 テーブル) | 変更なし。Task があれば動く | -| 問題の詳細ページ | `/problems/{task_id}` | 変更なし | -| 一覧の並び | 画面では `compareByContestIdAndTaskId()` がコンテストの優先度 → `contest_id` → 記号の順に並べ直す。DB から取る順(`task_id` の降順)は効かない | 変更なし | -| 入力チェック | zod は `task_id` が空でないことだけを見る | 変更なし | -| 問題 ID を数値として読む箇所 | コードでは `assignProblemLetters()` の `Number()` だけ。管理画面の AOJ 取り込みと、`seed_problem_letters.test.ts` の基本ルールの検査で使う。ほかに PR1 / PR2 のマイグレーション(`20261024033928`、`20261025000719`)が `ORDER BY task_id::int` で並べる | 取り込みはダミーを通らない。テストでは、ダミーのあるコンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れて検査から外す。マイグレーションは本番・staging とも適用済みで、新しい DB では seed より先に流れるので、ダミーの行を読むことはない | -| `task_id` を `_` で分ける箇所 | `getCourseName()` だけ。AOJ の API の応答にしか使わない | 変更なし | -| 解答状況 | ユーザーが自分で設定する。AOJ から提出結果を取り込む処理は無い | ジャッジデータで確かめて AC にできる | -| 管理画面の AOJ 取り込み | Task を削除しない([5 章](#5-task-テーブルへの書き込み経路の全数調査))。すでにある行は飛ばす | ダミーは消えない。欠けた問題が AOJ に後から足されたときだけ注意する([下記](#aoj-に後から追加されたとき)) | - -レコードの入れ方: - -- seed: `prisma/tasks.ts` に足す。`contest_type` は `classifyContest()` が `contest_id` から決める(ダミーの 7 コンテストは `AOJ_JAG`) -- staging / 本番: メンテナが Supabase の CSV で入れる。列と確認の SQL は [plan.md](./plan.md#staging--本番への投入メンテナ) - -## AOJ に後から追加されたとき - -3643 のように、AOJ は過去の問題を後から追加することがある。ダミーの問題が AOJ に入ったら、AOJ の ID に移す。 - -1. **管理画面から取り込まない**。取り込みは、AOJ のコンテスト一覧にある問題 ID の昇順で記号を振る(`assignProblemLetters()`)。すでにある行は飛ばすので今の記号は変わらないが、欠けたコンテストに後から足された問題には、ずれた記号が付く(例: 模擬地区 2005 の C が新しい ID で入ると、7 番目の G になる)。同じコンテストで記号が重なると、`generateTable` が片方を上書きし、問題が黙って表から消える(`(contest_id, task_table_index)` に一意制約は無い)。この危険は PR3 の前からあり、対象は管理画面から取り込める模擬地区(ダミーがあるのは 2005 / 2008)。合宿系は AOJ の一覧に無いので取り込めない -2. DB の `task_id` を AOJ の ID に UPDATE する。参照する外部キーは 6 つとも `ON UPDATE CASCADE`(taskanswer / tasktag / workbooktask / votegrade / votedgradecounter / votedgradestatistics)なので、回答・投票・タグ・問題集のデータは付いてくる。記号もそのまま残る -3. `no_judge.ts` の map から消し、seed の行の `id` と `seed_problem_letters.test.ts` の期待値を直す -4. 出典のラベルは `task_id` から決まるので、自動で `(AOJ)` に戻る - -移し忘れても、`NoJudge-` が残っていれば一目で分かる。 diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md deleted file mode 100644 index de90f8fb7..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ /dev/null @@ -1,331 +0,0 @@ -# AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する - -**日付**: 2026-09-09 作成、2026-10-28 に PR3 を実装し、全体を再編/ **状態**: PR0〜PR2.5 は完了(本番・staging で確認済み)。PR3 は実装・目視確認済み、未マージ。根拠データは [investigation.md](./investigation.md)。 - -## ゴール - -`problem_index`(DB 上は `task_table_index`)に AOJ 問題 ID ではなく**問題記号**を持たせ、表示側が変換なしにそれを使える状態にする。 - -> **問題記号**: `A`, `B`, `C`… のように、そのコンテスト内で何番目の問題かを表す記号。AtCoder では `abc300_a` の `a` にあたる。AOJ には存在しない(難所 1)。コード上は `letter`。 - -| | 変更前 | 目標 | -| ------------------------- | ---------------------------------------- | -------------------------------------------- | -| **G1** 出典欄 | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | -| **G2** コンテストテーブル | 問題 ID 3467 → ソート位置から `F` を算出 | `task_table_index` の `F` を**そのまま使う** | - -## 現在の状態と PR - -AOJ ICPC・JAG の全範囲(ICPC 国内予選 / アジア地区 / JAG 模擬国内 79 コンテスト、合宿系・模擬地区・in AtCoder 76 コンテスト)で `task_table_index` が問題記号になった。title は素(問題 ID も記号も付かない)で、セルは `task_table_index` から `A. タイトル` を組み立てる。 - -| PR | 内容 | 状態 | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | -| PR0 | 出典欄の AOJ の書式(#4212) | 完了 | -| PR1 | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を記号にする。表示側の変換を削除し、取り込みは基本ルールで記号を付ける(#4217) | 完了 | -| PR1.5 | AOJ API の移転(2026-09-07、旧ホストは 410)に追従する(#4219) | 完了 | -| PR2 | 合宿系 + 模擬地区(66 / 625)を記号にし、合宿系の title から問題 ID を外す(#4220) | 完了 | -| PR2.1 | `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテストと 2025 / 2026 の夏合宿(計 10 コンテスト)を足す(#4222) | 完了(本番のデータも投入済み) | -| PR2.5 | グループ「JAG 模擬地区」に 76 コンテストのテーブルを作る | 完了 | -| PR3 | AOJ のコンテスト一覧に無い 26 問を足す(AOJ に無い 21 問はダミー) | 実装・目視確認済み、未マージ。CSV の投入待ち | - -順序の制約: - -- **PR2 → PR2.5 は守る**。Provider を先に出すと、合宿系の数値の index と `'2389. …'` の title がそのまま表に出る -- **PR2.5 は PR2.1 の後**。AtCoder の 4 コンテストの Provider が `ATCODER_JAG` と PR2.1 の seed を前提にする。本番の PR2.1 のデータ投入が PR2.5 より後だと、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る -- **PR3 は独立**。PR2 で記号を補正済みなので、欠落を後から足しても既存の記号はずれない - -## 残りのタスク - -PR3: - -実装・Codex のクロスレビュー(指摘はテストの強化 1 件で反映済み)・開発サーバーでの目視(JAG 冬合宿 2006 Day2 の表と問題一覧で、ダミーのリンク先と `(No Judge)` を確認)は済んだ。 - -1. PR を開き、staging へマージする -2. コードのデプロイの後に、メンテナが staging へ CSV を投入し、[SQL](#staging--本番への投入メンテナ) で確かめる -3. `main` へ昇格した後、本番にも同じ CSV を投入して確かめる - -PR 外(優先度の低いものを含む): - -- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る(`getContests` 側もそろえる) -- seed 整合テストの期待値を `assignProblemLetters` で作っていて、testing.md の「期待値は literal」に合わない。補正したコンテストだけ literal にしている。全件を literal にするかは未判断 -- `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので触っていない -- テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める([名称の違い](./investigation.md#名称の違い)) -- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める - -### スコープ外 - -次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。 - -| 範囲 | 本番(コンテスト / 問題) | 今の出典欄 | 残す理由 | -| ----------- | ------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | -| AOJ Courses | 8 / 247 | `プログラミング入門 - ITP1_1_A(AOJ)` のように不格好 | 問題 ID が非数値で、基本ルールの `task_id::int` が使えない | -| AOJ PCK | 1 / 12 | 数値の問題 ID のまま | 優先度が低い | -| AOJ 大学系 | 27 / 30 | `UTPC 2010 - 2207(AOJ)` | 優先度が低い。本方式をそのまま当てられる | - -- 大学系に広げるときは、`AOJ-KUPC2011` の 2279 を移行 SQL の対象から外す(index は既に `I` で正しく、基本ルールでは `A` になる) -- 本番の title のうち 46 件は前後に空白がある(AOJ 4 件、AtCoder 42 件。[詳細](./investigation.md#前後に空白のある-title))。実害が無く、取り込みが trim するので再発もしない。直すなら 1 本の UPDATE(`btrim`、46 件の件数チェック付き)で済む - -## 3 つの難所 - -1. **問題記号は AOJ API から取得できない。** ただし**基本ルール**(コンテスト内で問題 ID が若い順に `A`, `B`, `C`…)で復元できる。⇒ 対応表はスクリプトで機械生成できる -2. **欠落があると基本ルールが破れる。しかも欠落はデータから検出できない。** 欠落とは、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いこと。AOJ に登録が無ければ ID も無く、残った問題の ID は連番のままになる。⇒ **コンテスト単位で外部情報(メンテナのスプレッドシート)と照合するしかない**(PR2 では 8 コンテストで欠落が見つかった。[照合結果](./investigation.md#記号の補正が要るコンテスト)) -3. **表示層で逃げられない。** 出典欄は `task_table_index` を直接読む。⇒ DB の値そのものを直す以外に手段がない - -## 決定事項 - -| 決定 | 理由 | -| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| DB の値そのものを A/B/C に直す。既存データは移行 UPDATE で直す | 難所 3。seed も取り込みも「既存なら skip」で、既存レコードは自動更新されない | -| 対応表は基本ルールで生成し、欠落は人間がスプレッドシートと照合する | 難所 2。下の[照合の手順](#スプレッドシートとの照合) | -| 管理画面の取り込みは、対応表ではなく基本ルールで記号を付ける。JAG は全 round(模擬国内 / 模擬地区)で付ける | 対応表を取り込み側に置くと、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。補正した 8 コンテストは既存行なので、取り込みは skip し補正は持たない | -| 移行は Prisma のデータマイグレーションで適用する | CI の既存の仕組みに乗る | -| 合宿系の `title` から問題 ID のプレフィックスを除く | index だけ記号にすると `removeTaskIndexFromTitle` が空振りし、`'2389. …'` が表に出る | -| 合宿系は管理画面の取り込みに足さず、seed と Supabase の CSV で入れる | 既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加には重い(メンテナの判断) | -| 表示側の変換と `getTaskLabels` を削除する | DB が記号を持てば変換は恒等になる。`getTaskLabels` だけを残すと、上書きする Provider が 0 の使われないコードが残る | -| ヘッダーの並びは `localeCompare` | 記号に `Number()` を当てると NaN になる | -| `buildTitleWithTaskIndex` は先頭の記号を外してから付ける。`removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す | AtCoder の title は `A. …` を含むので、付けるだけだと二重になる。本文の `. ` まで消すと `Dr. Podboq` → `DrPodboq` になる | -| AOJ ラベルから全角括弧を外す | 出典欄の目標書式ではコンテスト名を括弧で囲まない。検索・投票・タグ・取り込み画面などの表示も変わるが許容した | -| AtCoder 上の JAG 4 つは `ATCODER_JAG` を新設し、contest_id の完全一致で分類する | `jag` の前方一致だと `JAG2013Spring` / `JAG2014Spring`(AOJ の `JAGSpring2013` / `2014` と同じコンテスト)まで入り、二重になる。`AOJ_JAG` だと `(AOJ)` や `getTaskUrl` が AOJ 前提で動く | -| 合宿系・模擬地区・in AtCoder を 1 グループ「JAG 模擬地区」にし、テーブルはコンテストごと。並びは明示的な配列で持つ | 同じ年の中の前後関係(模擬地区 2014 は夏合宿 2014 の Day3)を表せるのは 1 グループだけ。スプレッドシートの行順は contest_id から導けない。配列の書き漏れは seed との整合テストで落ちる | -| テーブル名は `getContestNameLabel` の名称に括弧書き(`JAG` / `有志` / `in JAG 夏合宿 20xx Day3`)を付け、出典欄は変えない | スプレッドシートの名称にすると出典欄と食い違う。そろえるなら検索・投票・タグ・fixture に波及するので #4018 に回した。ラベル(出典欄)に(有志)は付けない | -| Provider の `contestType` は、AtCoder 上の 4 コンテストだけ `ATCODER_JAG`、ほかは `AOJ_JAG`。グループのキーは `aojJagRegional` | `contestType` が効くのは provider key と `getTaskTableHeaderName` だけで、表示は変わらない。キーは localStorage に保存されるので後から変えられない | -| seed の記号の検査は、補正した 8 コンテストだけ literal で書く。title は「問題名(`name`)と同じ」で見る | 補正の正しさは基本ルールの関数では検査できない。title は「問題 ID が無い」だけだと、空や別の問題にすり替わっても通る | - -却下した方針: - -- 対応表を `prisma/tasks.ts` に手書きし seed だけ直す: 取り込みが数値を入れ続け、新旧の書式が混在する -- 問題 ID の連番性から欠落を機械判定する: 論理的に成立しない(難所 2) -- seed を冪等化して production に流す: `addUsers` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts`) -- `title` に記号を入れて `'A. Peculiar Protocol'` に揃える: 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) -- 管理画面に同期 action を足す: 保留。問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) - -## PR3: 欠落問題の補完 - -根拠は [investigation.md の 7 章](./investigation.md#7-pr3-欠けている問題)。 - -AOJ の JAG / ICPC で、公式の問題なのに seed に無い 26 問を足す。管理画面の取り込みは AOJ のコンテスト一覧を使うので取り込めない。seed に足し、staging / 本番はメンテナが Supabase の CSV で入れる。 - -| 種類 | 問題 | 入れ方 | -| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| AOJ にあるが、取り込み元のコンテスト一覧に無い(5 問) | 1123(ICPC 国内予選 2002 の E)、3643(JAG 夏合宿 2024 Day3 の G)、2565〜2567(JAG 模擬地区 2013 の A〜C) | AOJ の問題 ID のまま、通常の問題として入れる | -| AOJ に無い(21 問) | 2005〜2008 年の JAG の 7 コンテスト([一覧](./investigation.md#aoj-に無い-21-問)) | ダミーの Task として入れ、リンクは jag-icpc.org に向ける | - -3643 は 2026-10-10 に AOJ に追加されたばかりで公開前かもしれない(`available` が 4、提出が 0)が、入れる。合宿系は取り込みの対象外なので、一覧に入るのを待っても取り込めない。 - -### ダミー(21 問)の決定 - -- **`task_id`**: `NoJudge-{contest_id}_{記号の小文字}`(例: `NoJudge-JAGWinter2006-day2_a`)。`task_table_index` は大文字のまま。接頭辞が「採点の仕組みが無い」という分岐の理由をそのまま表し、AOJ(数字)とも AtCoder(`abc300_a`)とも構造上ぶつからない。判定は `isNoJudgeTask()`(`startsWith('NoJudge-')`)に一本化する -- **title**: jag-icpc.org の年度ページの一覧(= スプレッドシート)の表記。`Wireing Assistant` も原文どおり -- **リンク先**: jag-icpc.org の大会区分のページ(例: `2008/Practice/模擬地区予選`、計 5 ページ)。問題文・ジャッジデータ・講評のどれにも下りられる。URL は組み立てず、ブラウザで開けたものをパーセントエンコードのまま貼る -- **リンクの出し方**: `NoJudgeGenerator` を `AojGenerator` の前に置く(ダミーは JAG の `contest_id` を使うので、順序に意味がある)。`task_id` をキーにした map から引き、漏れていれば空文字を返して AOJ には回さない。seed との突き合わせのテスト 2 件が、map の漏れと打ち間違いを守る -- **出典のラベル**: `(AOJ)` ではなく `(No Judge)`(例: `JAG 冬合宿 2006 Day2 - A(No Judge)`)。`addContestNameToTaskIndex` に必須の `taskId` を足した -- **見た目**: アイコンもツールチップも入れない。表のセルに幅が無く、スマホにはホバーが無い。引っかかっても、新しいタブで開けば提出フォームの無い Wiki だと分かる -- **記号の検査**: ダミーのある 7 コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れる(`Number()` で並べられないため、入れ忘れを検出するテストがある)。連番に戻った `JAGRegional2013` と `JAGSummer2024-day3` は外した -- **AOJ に後から追加されたら**: 管理画面から取り込まず、DB の `task_id` を AOJ の ID に UPDATE する。取り込みは後から足された問題へずれた記号を付け、重なると表から黙って消える。外部キー 6 つは `ON UPDATE CASCADE`([手順](./investigation.md#aoj-に後から追加されたとき)) - -### 採らなかった案 - -- **`task_id`**: 数字(100000 番台は AOJ の本物の ID と見分けられず、AOJ が決める領域で衝突が残る。50xx 番台は AOJ の採番がいずれ届く)、`Missing-`(AOJ にある 5 問にも当てはまり、仮のレコードにも読める)、`jag-icpc_` など(置き場所を表し、分岐の理由を表さない) -- **リンク先**: PDF への直リンク(講評に辿れず、添付ファイル名が揃っておらず切れやすい)、`問題文とデータセット` のページ(講評に辿れない) -- **リンクの出し方**: - - `getTaskUrl()` の先頭で map を引く: 判定が「map に載っているか」になり、出典のラベル(接頭辞で判定)と基準が割れる。漏れると開けない AOJ のリンクになる - - Task に `url` 列を足す: マイグレーションと呼び出し元 9 ファイルの変更が要り、21 件には重い。他サイトに対応するときに再検討する - - URL を年・大会区分・ファイル名から組み立てる: 規則の推測が外れると黙って間違ったリンクになる - - map を隠して取り出す関数だけを export する: seed との突き合わせで map のキーを列挙できない - - 外部リンクを CI で確かめる: 外部サイトの状態でテストが落ちる -- **判定の置き場所**: `isNoJudgeTask()` を `src/lib/utils/` に置く案は、`lib/utils/task.ts` が `#lib/contests/index.js` を import しているので循環 import になる。`lib/contests` に置いた -- **`taskId` を省略できる引数にする**: 渡し忘れが黙って `(AOJ)` のままになる。必須なら型チェックが漏れを見つける -- **見た目**: セルにアイコン(AOJ の表は 1 行に最大 6 列で幅が足りない)、`title` 属性のツールチップ(スマホでは出ない。知らせる仕組みが必要になったら最初に検討する)、表のタイトルに `note` で書く(主催者の欄と意味が混ざる)、詳細ページに一文添える(表から直接開く人の目に入らない) -- **title の確認**: 一覧でしか確かめていない 19 問を PDF の表紙でも確かめる案は、一覧がスプレッドシートと一致しているので見送った - -### staging / 本番への投入(メンテナ) - -コードのデプロイの後に CSV を入れる(先に入れると、デプロイまでの間、ダミーが開けない AOJ のリンクと `(AOJ)` のラベルで出る)。 - -- 列は `id`・`contest_type`・`created_at`・`updated_at` も明示する。Supabase の CSV は Prisma の既定値を使わず、`created_at` は DB に既定値があっても空になった例がある(`docs/guides/agent-rules/prisma-db.md`) -- `id` は PR2.1 と同じく、メンテナのスクリプトで `sha256(contest_id + title)` にする(`Task.id` を再計算して引く箇所は無い。[根拠](./investigation.md#id-の採番規則が経路ごとに違う)) -- CSV の投入は `invalidateTaskCaches()` を経ないので、画面に出るのはタスクのキャッシュの有効期限(1 時間)が切れた後 - -| `task_id` | `contest_id` | `contest_type` | `task_table_index` | `title` | -| ---------------------------- | ------------------ | -------------- | ------------------ | ---------------------------- | -| 1123 | ICPCPrelim2002 | AOJ_ICPC | E | Enclosing Circles | -| NoJudge-JAGRegional2005_c | JAGRegional2005 | AOJ_JAG | C | Geometry?! Why Not?? | -| NoJudge-JAGRegional2005_f | JAGRegional2005 | AOJ_JAG | F | University Rankings | -| NoJudge-JAGRegional2005_h | JAGRegional2005 | AOJ_JAG | H | Tiling Polygons | -| NoJudge-JAGRegional2008_j | JAGRegional2008 | AOJ_JAG | J | Colony Maintenance | -| 2565 | JAGRegional2013 | AOJ_JAG | A | Broken Audio Signal | -| 2566 | JAGRegional2013 | AOJ_JAG | B | Restore Calculation | -| 2567 | JAGRegional2013 | AOJ_JAG | C | SIRO Challenge | -| NoJudge-JAGSummer2006-day1_a | JAGSummer2006-day1 | AOJ_JAG | A | Monkey Business | -| NoJudge-JAGSummer2006-day1_c | JAGSummer2006-day1 | AOJ_JAG | C | Sort that Queue | -| NoJudge-JAGSummer2006-day3_a | JAGSummer2006-day3 | AOJ_JAG | A | Rolling Cubes | -| 3643 | JAGSummer2024-day3 | AOJ_JAG | G | Interactive String Guessing | -| NoJudge-JAGWinter2006-day2_a | JAGWinter2006-day2 | AOJ_JAG | A | Cells | -| NoJudge-JAGWinter2006-day2_b | JAGWinter2006-day2 | AOJ_JAG | B | Japan | -| NoJudge-JAGWinter2006-day2_c | JAGWinter2006-day2 | AOJ_JAG | C | Maximum | -| NoJudge-JAGWinter2006-day2_e | JAGWinter2006-day2 | AOJ_JAG | E | Payment System | -| NoJudge-JAGWinter2006-day2_g | JAGWinter2006-day2 | AOJ_JAG | G | Roof | -| NoJudge-JAGWinter2006-day2_h | JAGWinter2006-day2 | AOJ_JAG | H | Seminar Room | -| NoJudge-JAGWinter2006-day2_i | JAGWinter2006-day2 | AOJ_JAG | I | String Compression | -| NoJudge-JAGWinter2006-day3_b | JAGWinter2006-day3 | AOJ_JAG | B | Widget Factory | -| NoJudge-JAGWinter2006-day3_c | JAGWinter2006-day3 | AOJ_JAG | C | Hills | -| NoJudge-JAGWinter2006-day3_e | JAGWinter2006-day3 | AOJ_JAG | E | Safe Packing | -| NoJudge-JAGWinter2006-day3_f | JAGWinter2006-day3 | AOJ_JAG | F | The "Shortest" Pair of Paths | -| NoJudge-JAGWinter2006-day3_i | JAGWinter2006-day3 | AOJ_JAG | I | Counting Triangles | -| NoJudge-JAGWinter2006-day3_j | JAGWinter2006-day3 | AOJ_JAG | J | Wireing Assistant | -| NoJudge-JAGWinter2007-day3_e | JAGWinter2007-day3 | AOJ_JAG | E | Networking Company | - -CSV を入れたら、次の SQL で確かめる。seed はテストが守るが、CSV は守らない。 - -- 記号: `(contest_id, task_table_index)` には一意制約が無く、同じコンテストで記号が重なると、表で片方の問題が黙って消える -- 26 行の中身: 記号の集計だけでは `task_id` の打ち間違いが通る。`NoJudge-` の綴りを誤ると `(AOJ)` と開けない AOJ のリンクで出て、AOJ の ID を誤ると別の問題に飛ぶ。`contest_type` の列を CSV から落とすと、DB の既定値 `ABC` で黙って入る。最後の SQL で `prisma/tasks.ts` の値(`contest_type` は `classifyContest()` の結果)と 1 行ずつ突き合わせる - -```sql --- Expect no rows: a duplicated letter hides one problem in the contest table. -SELECT contest_id, task_table_index, COUNT(*) -FROM task -WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') -GROUP BY contest_id, task_table_index -HAVING COUNT(*) > 1; - --- Expect the letters in the table below. -SELECT contest_id, COUNT(*) AS task_count, STRING_AGG(task_table_index, '' ORDER BY task_table_index) AS letters -FROM task -WHERE contest_id IN ('ICPCPrelim2002', 'JAGRegional2005', 'JAGRegional2008', 'JAGRegional2013', 'JAGSummer2006-day1', 'JAGSummer2006-day3', 'JAGSummer2024-day3', 'JAGWinter2006-day2', 'JAGWinter2006-day3', 'JAGWinter2007-day3') -GROUP BY contest_id -ORDER BY contest_id; - --- Read-only. Expect no rows: the 26 rows match prisma/tasks.ts (catches task_id typos the letter checks miss). -SELECT expected.* -FROM (VALUES - ('1123', 'ICPCPrelim2002', 'AOJ_ICPC', 'E', 'Enclosing Circles'), - ('NoJudge-JAGRegional2005_c', 'JAGRegional2005', 'AOJ_JAG', 'C', 'Geometry?! Why Not??'), - ('NoJudge-JAGRegional2005_f', 'JAGRegional2005', 'AOJ_JAG', 'F', 'University Rankings'), - ('NoJudge-JAGRegional2005_h', 'JAGRegional2005', 'AOJ_JAG', 'H', 'Tiling Polygons'), - ('NoJudge-JAGRegional2008_j', 'JAGRegional2008', 'AOJ_JAG', 'J', 'Colony Maintenance'), - ('2565', 'JAGRegional2013', 'AOJ_JAG', 'A', 'Broken Audio Signal'), - ('2566', 'JAGRegional2013', 'AOJ_JAG', 'B', 'Restore Calculation'), - ('2567', 'JAGRegional2013', 'AOJ_JAG', 'C', 'SIRO Challenge'), - ('NoJudge-JAGSummer2006-day1_a', 'JAGSummer2006-day1', 'AOJ_JAG', 'A', 'Monkey Business'), - ('NoJudge-JAGSummer2006-day1_c', 'JAGSummer2006-day1', 'AOJ_JAG', 'C', 'Sort that Queue'), - ('NoJudge-JAGSummer2006-day3_a', 'JAGSummer2006-day3', 'AOJ_JAG', 'A', 'Rolling Cubes'), - ('3643', 'JAGSummer2024-day3', 'AOJ_JAG', 'G', 'Interactive String Guessing'), - ('NoJudge-JAGWinter2006-day2_a', 'JAGWinter2006-day2', 'AOJ_JAG', 'A', 'Cells'), - ('NoJudge-JAGWinter2006-day2_b', 'JAGWinter2006-day2', 'AOJ_JAG', 'B', 'Japan'), - ('NoJudge-JAGWinter2006-day2_c', 'JAGWinter2006-day2', 'AOJ_JAG', 'C', 'Maximum'), - ('NoJudge-JAGWinter2006-day2_e', 'JAGWinter2006-day2', 'AOJ_JAG', 'E', 'Payment System'), - ('NoJudge-JAGWinter2006-day2_g', 'JAGWinter2006-day2', 'AOJ_JAG', 'G', 'Roof'), - ('NoJudge-JAGWinter2006-day2_h', 'JAGWinter2006-day2', 'AOJ_JAG', 'H', 'Seminar Room'), - ('NoJudge-JAGWinter2006-day2_i', 'JAGWinter2006-day2', 'AOJ_JAG', 'I', 'String Compression'), - ('NoJudge-JAGWinter2006-day3_b', 'JAGWinter2006-day3', 'AOJ_JAG', 'B', 'Widget Factory'), - ('NoJudge-JAGWinter2006-day3_c', 'JAGWinter2006-day3', 'AOJ_JAG', 'C', 'Hills'), - ('NoJudge-JAGWinter2006-day3_e', 'JAGWinter2006-day3', 'AOJ_JAG', 'E', 'Safe Packing'), - ('NoJudge-JAGWinter2006-day3_f', 'JAGWinter2006-day3', 'AOJ_JAG', 'F', 'The "Shortest" Pair of Paths'), - ('NoJudge-JAGWinter2006-day3_i', 'JAGWinter2006-day3', 'AOJ_JAG', 'I', 'Counting Triangles'), - ('NoJudge-JAGWinter2006-day3_j', 'JAGWinter2006-day3', 'AOJ_JAG', 'J', 'Wireing Assistant'), - ('NoJudge-JAGWinter2007-day3_e', 'JAGWinter2007-day3', 'AOJ_JAG', 'E', 'Networking Company') -) AS expected(task_id, contest_id, contest_type, task_table_index, title) -LEFT JOIN task - ON task.task_id = expected.task_id - AND task.contest_id = expected.contest_id - AND task.contest_type::text = expected.contest_type - AND task.task_table_index = expected.task_table_index - AND task.title = expected.title -WHERE task.id IS NULL; -``` - -| `contest_id` | 問題数 | 記号 | -| ------------------ | ------ | ----------- | -| ICPCPrelim2002 | 5 | ABCDE | -| JAGRegional2005 | 9 | ABCDEFGHI | -| JAGRegional2008 | 10 | ABCDEFGHIJ | -| JAGRegional2013 | 10 | ABCDEFGHIJ | -| JAGSummer2006-day1 | 3 | ABC | -| JAGSummer2006-day3 | 7 | ABCDEFG | -| JAGSummer2024-day3 | 11 | ABCDEFGHIJK | -| JAGWinter2006-day2 | 9 | ABCDEFGHI | -| JAGWinter2006-day3 | 10 | ABCDEFGHIJ | -| JAGWinter2007-day3 | 10 | ABCDEFGHIJ | - -## データ移行の共通手順 - -PR2(記号の移行)、PR2.1(enum の追加)、PR3 に当てはまる。 - -### CI での適用 - -`.github/workflows/ci.yml`(2026-10-24 に確認)は、build / lint / check / unit-test が通ると、`main` 以外では `preview` ジョブ(適用先は staging の DB)、`main` では `production` ジョブを走らせる。どちらも `vercel build` の後・デプロイの前に `prisma migrate deploy` を流す。 - -- **PR の CI でも staging の DB に適用される**。`preview` ジョブの条件が `github.ref != 'refs/heads/main'` だけで、PR の ref も当たる。staging へのマージより前である -- このため SQL の確認は PR を開く前にローカルで済ませる。適用済みのマイグレーションは編集しても再適用されないので、後から誤りが見つかったら新しいマイグレーションを積む -- `main` へ昇格した時点で production に適用されるので、staging での確認が自然にゲートになる -- 本件の範囲外だが、`preview` ジョブの「Assign staging domain」はコメントに「if staging branch」とあるのに条件は `github.ref != 'refs/heads/main'` で、PR のデプロイにも staging のドメインが付け替わる - -### 手順 - -1. `pnpm exec prisma migrate dev --create-only --name `、SQL を `migration.sql` に書く -2. ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する。コンテストの切り替わり目(記号が `A` に戻る箇所)と問題数の少ない古いコンテストは目視する価値がある -3. `pnpm exec prisma migrate dev` で適用し、表示を確認する(**ここまで必ず PR を開く前に**) -4. PR を開く → staging へマージして確認 → `main` へ昇格 - -staging が防げないのは、対応表・欠落判断そのものの誤り。誤った記号でも SQL は完走し、それらしく表示される(難所 2)。 - -### 事故対策 - -- **爆発半径**: 一括で復元できるのは AOJ ICPC / JAG だけ(`task_table_index == task_id` が成り立っていたため)。WHERE 句が壊れて AtCoder 系に波及すると一括復元の手段が無くなる([実測](./investigation.md#復元可能性の実測)) -- **復元**: 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる。再修正時は Supabase Pro の PITR が唯一の手段 -- **`updated_at`**: `@updatedAt` は Prisma Client 側の実装で、生 SQL では更新されない。意図的に含めない -- **キャッシュ**: TTL 1 時間(`src/lib/clients/cache.ts`)。生 SQL と CSV の投入は `invalidateTaskCaches()` を経由しない。「更新したのに表示が変わらない」ときはこれを疑う -- **トランザクション**: マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで 1–2 文目も残らないことをローカルで確認) -- **デプロイ直前の数分**: マイグレーションの適用から Vercel のデプロイ完了までは、旧コードが新しいデータを描画する。数分で解消するので許容する - -### スプレッドシートとの照合 - -1. 基本ルールで対象範囲の記号を生成する -2. コンテスト単位で、メンテナのスプレッドシートと照合する(人手が要るのは書き写しだけ) -3. 先頭・途中に欠落があるコンテストは、スプレッドシートの位置から記号を決める(末尾の欠落は記号がずれないので補正不要) -4. 欠落した問題が AOJ に問題番号で存在するかを確かめ、PR3 に引き継ぐ - -- 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても前詰めでずれた記号が出るだけで、表示は成立する -- スクリプトには総数のアサーションを入れる。抽出漏れが欠落の誤検出に直結する([事例](./investigation.md#6-調査中の誤りと訂正自戒))。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。スプレッドシート自体の誤りは検出できない -- PR1 / PR2 のスクリプトは git に残っている: `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`、`git show c605d3bd:…/generate_aoj_jag_problem_letters.ts`。わざと誤りを入れたコピー 5 種類(順番の入れ替え、途中の title の誤り、欠番、問題数の不足、切れた title の前半の誤り)を、どちらも検出した - -## 実装中に分かったこと - -コードと git から読み取れない事実だけを残す。 - -**移行 SQL(PR1 / PR2)** - -- 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る。合宿系も模擬国内と同じ `AOJ_JAG` に分類されるので、PR1 で `contest_type` だけで絞ると合宿系 467 件まで更新する。PR2 は `^JAG(Summer|Winter|Spring|Regional)` で絞る -- PR1 は対応表を焼き込まず基本ルールを SQL で表した。本番は管理画面の取り込みで入っていて seed と一致する保証が無く、`UPDATE` は 0 件でもエラーにならないので、取りこぼしが静かに起きる -- PR2 の補正 8 コンテスト(45 行)は、コンテストの全行を上書きして 1 つのコンテストに 2 つの規則を混ぜない。各ステップの更新件数を DO ブロックで検査し、想定外なら RAISE する(想定値: 空の DB 0、模擬地区の無い開発 DB 467 / 32 / 467、staging・本番 625 / 45 / 467)。模擬地区が一部だけの DB では RAISE し、エラー文で「全部取り込むか、全部消す」と案内する -- 順位はコンテストの全行で付け、更新は問題 ID のままの行だけにする(Codex の指摘)。順位を付ける側でも絞ると、記号の行と問題 ID の行が混ざったコンテストで A から振り直してしまう。title から外すのは「自分の `task_id` + `. `」で始まるものだけ(`^[0-9]+\. ` だと番号で始まる本来の title まで削る) -- `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない -- `row_number()` は `bigint` を返し `chr(bigint)` は無いので `::int` が要る -- PR2 の staging は当初、模擬地区が 2 コンテスト / 16 問で件数チェックが RAISE するところだった。PR1.5 の後に staging で取り込み、本番と同じ状態(16 / 158、合宿系 50 / 467)にそろえてから PR を開いた - -**取り込みと AOJ API(PR1 / PR1.5)** - -- AOJ API には 2 日開催を 1 コンテストにしたもの(`JAGPrelim20162`、2738–2751)があり、取り込みでは A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれていて、取り込みは既存の問題を skip するので影響しない -- 新しい API とのコードの差はベース URL だけで、応答は最上位の `largeCl.id` が小文字になっただけ(コードは読まない) -- `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった -- 取得ツールが新しいホストに `ECONNREFUSED` で拒まれたが、Vercel からは問題なく取得できた。問題ページ `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける -- テスト: クライアントを nock のまま本番の定数から作り(以前は自前の旧ホストで、定数を間違えても通った)、`nock.disableNetConnect()` を足した。AOJ の fixture は取り直さない(統計値や新しい回の追加で、fixture に依存する期待値まで変わるため) - -**分類と表示(PR1 / PR2.1 / PR2.5)** - -- 表示の回帰: 旧コードで適用前、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 -- AtCoder の 4 コンテストのセルは AtCoder に飛ぶ(`getTaskUrl` が AOJ に振るのは大文字の `JAG…` だけ)。title の記号は二重にならない -- DB の `contest_type` は書き込むだけで、読む箇所は無い(分類は毎回 `contest_id` から引く)。enum の値は `ADD VALUE` で DB 上の末尾に足され、schema.prisma の並びと違うが、並べ替えにも使っていない -- PR2.5 のテストは、配列の contest_id を seed の合宿系・模擬地区・AtCoder 上の JAG と突き合わせる(手書きの 76 件の誤りは、計画の件数・並びのテストでは検出できない)。並びと括弧書きは investigation.md の表と機械的に突き合わせ、76 件すべて一致した - -**seed とデータの投入(PR2 / PR2.1)** - -- seed の模擬地区 158 問は本番から取り出したもので、AOJ の fixture と ID・コンテスト・名前がすべて一致した -- 2025 / 2026 夏合宿は、PR2 の移行の後に CSV で記号付きで入れる(移行の前に問題 ID のまま入れると PR2 の件数の想定値が変わる)。CSV の `id` は既存の合宿系と同じ `sha256(contest_id + title)` -- seed の AOJ 72 行は、既存の AOJ のブロックにそろえて古い順・問題 ID の昇順に置いた(add-tasks の新しい順・降順は AtCoder 側の慣習) -- PR2.1 のデータは、2026-10-25 にメンテナがローカルと staging で投入・確認した(10 コンテストで `ATCODER_JAG` 42 行・`AOJ_JAG` 72 行) -- PR2.1 より前の版へロールバックすると、旧版の Prisma Client は `ATCODER_JAG` を読めない。ロールバックの前に該当 42 行を `OTHERS` に変えて件数を控え、ロールフォワードの後に件数チェック付きの UPDATE で戻す。件数が違えば RAISE させ、期待値を書き換えて通さない(手順の SQL は git の履歴にある) - -**開発環境** - -- ローカルの管理画面の取り込みが 500 になった原因は 2 つ。外向き通信の許可リスト(`.devcontainer/init-firewall.sh`)に新しいホストが無かったこと(起動時に 1 回だけ DNS で IP を引くので、反映にはコンテナの再ビルドが要る。旧ホストは残さず置き換えた)と、enum を足す前から動いている開発サーバーが古い Prisma Client を持っていたこと(再起動で直る) -- PR2.1 より前に seed したローカル DB には 2025 / 2026 の夏合宿が無く、「JAG 模擬地区」にテーブルが出ない。`pnpm db:seed` を流し直せば入る