Skip to content
moriyama-devPublic

About

Swim meet results for parents and young swimmers of a club: latest swim vs. previous, progress per event, rank among same-age and club peers (PHP 8.2, MySQL)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

26 Commits

Folders and files

Repository files navigation

DropTime

In English: a small web app for the parents of one swim club. It pulls meet results, then shows each swimmer's latest race next to the previous one, progress per event, and where they rank among kids the same age. Not a commercial product.

  • Plain PHP 8.2 and MySQL 8. No framework, no dependencies, no build step
  • Parser for Hy-Tek MEET MANAGER result pages (individual, relay, prelims/finals, splits, DQ/NS), checked against 3 real meets (about 17,000 lines) and guarded by 6 fixture tests
  • One admin button fetches new meets from the live results site and only re-reads the ones that changed
  • Saves proof of where each record came from (screenshot, raw HTML, SHA-256 hash chain) so the data can always be traced back to a public page
  • Strict CSP, charts drawn as SVG, toggles built with CSS :has() and no JavaScript
  • All swimmer names in this repo are made up. A pre-commit hook blocks real names

The rest of this README is in Japanese.


競泳大会の情報を保護者向けに表示するための小さな Web アプリ。 ひとつの水泳クラブの保護者だけが使う前提で、商用化はしない。

  • 依存ライブラリなし(PHP 8.2 の標準機能のみ)
  • ビルド成果物なし
  • DB は MySQL 8 系
  • 公開されるのは public/ だけ。DB 接続情報・アップロード元ファイル・ログは すべてドキュメントルートの外に置く

セットアップ手順は docs/SETUP.md、 本番へのデプロイは docs/DEPLOY.md を参照。

名前はすべて架空(個人情報について)

このリポジトリに出てくる選手の名前は、すべて架空の名前に置き換え済みです。実在の人物とは関係ありません。

  • テストデータ(tests/fixtures/・tests/cases/)は実際の大会結果の書式から作りましたが、 選手名は同じ文字数の架空の名前(例: Buza, Divenim)に置き換えてあります。タイム・年齢・チームは書式の検証用にそのまま使っています
  • 説明書の例に出てくる Doe, Jane も見本の名前です
  • 実際の選手データ・出典の証拠・設定は git に入れず(var/・config/config.php)、運用中のサーバーだけにあります
  • コミットと push の前に bin/privacy-check.php が実名を探し、見つけたら止めます(.githooks/)

All swimmer names in this repository are fictitious. Test fixtures were built from the format of real meet results, but every name was replaced with a made-up one of the same length. Real data never enters git; a pre-commit / pre-push check (bin/privacy-check.php) blocks real names.

いまの状態

  • 認証(クラブ共通の合言葉 + 管理用の第 2 合言葉)、レート制限、検索避け
  • 取り込み: アップロード → pdftotext / HTML → パース → 確認画面 → DB 投入
  • Hy-Tek MEET MANAGER 結果ページのパーサー(M4)実装済み。 速報サイト(results.swimmeets.ca 等)の HTML をそのまま読める。 個人・リレー・予選/決勝・スプリット・DQ/NS・タイムトライアル対応。 実データ 3 大会(延べ 1.7 万行)で検証し、フィクスチャ 6 種で回帰を守る
  • 保護者画面: 次に泳ぐレース(推定時刻付き)・大会プログラム・結果・ お気に入り(端末ごとに保存し、ログアウトしても残る。引き継ぎコードで端末をまたぐ。「この端末のお気に入りを消す」あり)
  • 成績タブ: ① 最新の記録と前回の比較 ② 種目ごとの推移 ③ 同じ年齢・同じクラブでの順位(下記)
  • 最新の記録を取得: 管理画面のボタン 1 つで速報サイトから新しい大会を取り込む(下記)
  • クラブの仲間と比べる: 同じクラブ・年の近い選手と自己ベストを種目ごとに比較(下記)
  • データの出典: 取得元ページを公開状態で保存した証拠(スクショ・原本・SHA-256・台帳)を表示(下記)
  • 選手の同一人物判定と、取り消し可能な統合の管理画面

成績タブ(順位と推移)

/scores/{選手}。下部タブの 4 つ目。上から次の順に並ぶ。

  1. 最新の記録: いちばん新しい大会の泳ぎを、その 1 つ前の泳ぎと横棒で並べる。縮めた区間は金色に光る。 「前回と比べる」スイッチで 1 つ前を隠せる。同じプールの長さで初めての種目は、別の長さ(25m / 50m)の 1 つ前を参考に出し、50m プールなのに速ければそれを讃える(50m プールはふつう遅くなるため)
  2. 種目ごとの推移: 種目ごとに縮めた秒数の棒グラフ、2 回以上泳いだ種目の折れ線(上ほど速い)、 1 回だけの種目の一覧
  3. 順位: 種目ごとに「同じ年齢」(いちばん新しいレースでの同い年の中の順位)と 「同じクラブ」(同じクラブ・同じ性別・生年 ±1 の仲間の中での自己ベストの順位)を棒で並べる。 それぞれスイッチで ON / OFF できる

スイッチはチェックボックスと CSS の :has() だけで動く(JavaScript なし)。棒はすべて Chart\BarSvg(インラインの style 属性を CSP が許さないので、長さを SVG の属性で持つ)。

順位は同年齢を主役にする

年代別の部(10 & Under など)は 2〜3 歳差を同居させる。この年頃の 2 歳差は 体格差がそのままタイム差になるので、全体順位だけでは実力が読めない。

実データで違いは決定的で、同じレースが「全体 10 位 / 24 人」と 「9 歳の中で 1 位 / 7 人」の両方になる。全体だけを出すと 「96 位 / 133 人」のような表示になり、やる気を喚起する目的に反する。 そこで同年齢の順位を大きく出し、全体順位は事実として小さく併記する。

同い年が 1 人しか居ない種目では「1 位」を作らず、全体順位に落とす (FieldRank::MIN_PEERS)。順位は DB の place ではなくタイムから数え直す。 同年齢に絞った順位を同じ規則(同着は同順位)で出すため。

レース時点の年齢

順位の絞り込みには results.age(マイグレーション 003)を使う。

Hy-Tek の結果には「その大会時点の年齢」しか載らない。そこから生年を逆算すると 大会日と誕生日の前後関係で必ず ±1 年の曖昧さが残るが、比較に要るのは 「そのレースで同い年だったのは誰か」だけなので、年齢をそのまま記録している。 選手の同一人物判定は従来どおり swimmers.birth_year を使う(無関係)。

003 より前に投入済みの大会は、原本から埋め戻せる:

php bin/backfill-result-age.php var/manual/sources/<大会>          # 下見
php bin/backfill-result-age.php var/manual/sources/<大会> --write  # 更新

age 列だけを UPDATE する。行の追加・削除も選手 ID の変更もしないので、 お気に入りや統合の記録には影響しない(取り込みのやり直しは不要)。

動き

グラフと帯は画面に入ったときに一度だけ動く(棒が伸び、線が伸び、点が順に出る)。 動き自体は CSS アニメーションで、public/assets/app.js(IntersectionObserver)が 画面に入るまで止めておき、入った要素に .is-inview を付けて再生させる。 JS が読めない環境では、表示された瞬間に一度だけ動く。OS の 「視差を減らす」設定では prefers-reduced-motion で無効になる。

折れ線に pathLength="100" を宣言してあるので、実際の長さに関係なく stroke-dasharray: 100 の 1 通りで描画アニメーションが書ける。 CSP が script-src 'self' かつインライン style 属性を許さないため、 要素ごとの遅延は nth-child で与えている。

クラブの仲間と比べる

/scores/{選手}/club。成績タブ上部の切り替え「クラブの仲間」。

大会での順位が「その日に同じレースへ出た全クラブの選手」との比較なのに対し、 こちらは「普段一緒に練習している、年の近い子たち」との比較。大会も日付も違う 記録を並べるので、種目(距離・泳法・コース)ごとの自己ベストどうしで並べる。

  • 対象: 選手の所属クラブ(swimmers.team_id)で、性別が同じ選手(?gender=all で男女とも)
  • 年の近さ: 生年 ±1 歳(?band=2 で ±2 歳)
  • 生年はほぼ未登録なので、各結果の「大会日とその時の年齢」から誕生日のあり得る範囲を 重ねて推定する(Domain/BirthEstimate)。誕生日をまたいだ 2 大会があれば生年は 1 つに決まる。 範囲が少しでも重なれば仲間に入れる(取りこぼしより広めに拾う)

データの出典(公開状態の証拠)

このアプリの記録は、ネット上で誰でも見られた情報だけから作る。それをいつでも示せるよう、 取得元ページをログインも Cookie も無しで開いた状態で保存し、/sources に出す。

1 回の保存は var/evidence/<日時>-<URL>/ の 1 ディレクトリ:

ファイル 中身
meta.json 取得元 URL・取得日時(UTC)・HTTP 応答とヘッダ・取得条件・各ファイルの SHA-256
screenshot.png ヘッドレス Chrome(新しいプロファイル)で開いた全画面
raw.html サーバーが返した HTML そのもの(Cookie なしの HTTP 取得)
rendered.html 描画後の DOM
files/ 取り込みに使った原本(原本の登録のときだけ)
thumb.png 一覧用の縮小画像(派生物なのでハッシュ対象外)

保存のたびに var/evidence/ledger.jsonl に 1 行追記し、各行は直前の行のハッシュを含む (ハッシュの鎖)。画面を開くたびに全ファイルの SHA-256 と鎖を検証して結果を出す。 第三者の HTML は実行させないよう、必ずテキストのダウンロードとして返す。

php bin/capture-source.php --all-meets                     # 全大会の出典 URL
php bin/capture-source.php --swimmer-pages "Doe, Jane" # その選手が載った結果ページ
php bin/capture-source.php --originals var/manual/sources/<大会> --source <URL> --meet <ID>
php bin/capture-source.php <URL> --meet <ID>                # 任意の 1 ページ
php bin/capture-source.php --verify                         # ハッシュと鎖の検証

ヘッドレス Chrome は ~/.local/share/headless-chrome/chrome(sudo 不要で導入。 Chrome for Testing の headless shell と、足りない共有ライブラリ・日本語フォントを apt download で取り出したもの)。共有ホスティングでは動かないので、 開発環境で撮って var/evidence を本番へ運ぶ(deploy/dreamhost/deploy.sh が行う)。 新しい大会を取り込んだら撮り忘れないよう、未保存の大会は管理画面に出る。

最新の記録を取得(管理画面のボタン)

管理画面の一番上の「最新の記録を取得する」を押すと、あとは自動で次を行う。

  1. 速報サイト(results.swimmeets.ca)のフォルダ一覧を読み、全部の大会(古い大会も、前回クラブの選手が出ていなかった大会も)について 目次と「最後に終わった種目」ページの更新日時を見て、前回から中身が変わった大会(または一度も中身を見ていない大会)を選ぶ
  2. 選んだ大会の種目ページを全部保存する(var/manual/sources/<大会>)
  3. 結果のページだけを残す(大会前の Psych Sheet はエントリータイムなので取り込まない)
  4. クラブ(config/config.php の fetch.club_code。既定は無く、空なら止まる)の選手が 1 人でも載っていれば、取り込んで 確認画面を通さずに DB へ投入する
  5. 新しく投入した大会は、出典の証拠(大会ページ・原本・お気に入り選手が載ったページ)も保存する

取得は数分かかるので、ボタンは bin/fetch-latest.php を裏で起動するだけで、進み具合は /admin/fetch に出る (実行中は 3 秒ごとに自動更新)。手で実行してもよい:

php bin/fetch-latest.php                    # 全大会を確認し、中身が変わった大会を読み直す
php bin/fetch-latest.php --folder 2026hof   # その大会だけを、状態に関係なく取り直す
php bin/fetch-latest.php --no-evidence      # 証拠のスクショを撮らない
  • どの大会をどこまで見たかは var/fetch/state.json。毎回の確認は 1 大会あたり 2 回の通信(目次と最後の種目の更新日時)で、 種目ページを全部読み直すのは中身が変わった大会だけ。一度も中身を見ていない大会は、最初の 1 回だけ全ページを読む (全大会ぶんで 15〜20 分ほど。2 回目からは 1 分前後)
  • 大会の最中は、フォルダの日時が変わらないまま同じファイル名の Psych Sheet が結果に置き換わっていく。 目次と「最後に終わった種目」ページの更新日時で、それも見分ける
  • 同じ大会を投入し直しても二重にならない(種目単位で置き換え)。選手は前回その大会で結び付けた子を そのまま使う(SwimmerMatcher::rememberMeet)ので、同姓同名の子が新しい選手に分かれることもない。 管理者が確認画面で直した大会情報(主催・会場)も上書きしない
  • 二重起動はロック(var/fetch/run.lock の flock)で防ぐ

ディレクトリ

パス 役割
public/ Web 公開対象。フロントコントローラと静的ファイルのみ
src/ アプリ本体。DropTime\ 名前空間
src/Parser/ ヒートシート・結果のパーサー(実書式は M4 で追加)
src/Import/ 取り込みフロー(保存・テキスト化・確認・投入)
src/Matching/ 選手・チームの同一人物判定と統合
src/Repository/ 保護者向け画面の読み取りクエリ
src/Chart/ SVG のグラフ(推移の折れ線・順位の帯)
src/Evidence/ 出典の証拠保全(ヘッドレス Chrome の操作・保存と検証)
src/Fetch/ 速報サイトからの最新の記録の自動取得
templates/ 素の PHP テンプレート
migrations/ 平文 SQL のマイグレーション
config/ 設定(config.php は git 管理外)
bin/ CLI スクリプト
tests/ 自前のテストランナー用。fixtures/ にパーサーのフィクスチャ
var/ 実行時データ。git 管理外・Web 到達不能

よく使うコマンド

php bin/migrate.php --status      # マイグレーションの適用状況
php bin/migrate.php               # 未適用のマイグレーションを適用
php bin/hash-password.php         # 合言葉のハッシュを生成
php bin/test.php                  # テスト
php bin/lint-php82.php            # 本番 PHP 8.2 との互換性チェック
php bin/backup.php                # DB バックアップ
php bin/check-prod.php            # デプロイ前後の点検
php bin/import-hytek.php <dir>    # 保存した Hy-Tek 結果一式を確認待ちに載せる
php bin/import-manual.php <json>  # 結果の手入力取り込み
php bin/backfill-result-age.php <dir>  # 投入済みの結果にレース時点の年齢を埋め戻す
php bin/seed-demo.php --force     # 開発用の架空データ投入(dev 限定)
php bin/make-icons.php            # PWA アイコンの再生成(GD)
php bin/capture-source.php ...    # 出典の証拠を保存(上記)
php bin/transfer-data.php export|import <file>  # 大会データを別環境へ運ぶ
php bin/dev-screenshot.php <dir> <path>... --fav <選手ID>  # ログイン後の画面を撮る(開発用)
deploy/dreamhost/deploy.sh        # 本番へ反映(docs/DEPLOY.md)
bin/dev-mysql.sh start            # 開発用 MySQL(sudo 不要・ポート 3307)
php -S 127.0.0.1:8787 -t public bin/dev-router.php   # 開発サーバ
bin/smoke-test.sh                 # 認証と取り込みの疎通確認

大会結果の取り込み方

  1. 速報サイト(例: results.swimmeets.ca)から大会フォルダごと保存する
  2. php bin/import-hytek.php <フォルダ> で確認待ちに載せる (PDF や単一 HTML なら管理画面から直接アップロードでもよい)
  3. 管理画面の確認画面で認識件数・未認識行を見て「投入」
  4. 同じ大会を再投入しても種目単位で置き換わり、二重登録にはならない

パーサーを追加・修正するとき

  1. 実データを tests/fixtures/<パーサー名>.<ケース名>.in.txt に置く (pdftotext -layout の生出力、または HTML から抜いたテキストをそのまま)
  2. 期待値 <同名>.expected.json を書く(ParseResult::toArray() と一致)
  3. src/Parser/ に実装し、ParserRegistry::default() に登録
  4. php bin/test.php で全フィクスチャが回る。 取り込み済みファイルは管理画面の「再パース」で処理し直せる。 書式を推測で書かないこと。必ず実データを先にフィクスチャへ

About

Swim meet results for parents and young swimmers of a club: latest swim vs. previous, progress per event, rank among same-age and club peers (PHP 8.2, MySQL)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages