綴Photo のモバイル版で、期間選択画面に「写真なし月を隠す」というボタンがあります。月別の本棚から、写真が1枚もなかった月のスロットをまとめて非表示にしてくれる便利機能。
これが、今日が 5月14日でも 2026年6〜12月を「写真なし=完了」として隠せてしまう 状態だった。未来の月はそりゃ写真がなくて当然なので、本来ならスキャン対象に入れちゃいけません。
実害は小さいけど、地味に放置するとユーザーが「あれ?今年12月分のはずがもう完了になってる?」と混乱しえます。境界条件の見落としネタとして残す。
この記事の前提: Codex の指摘で気づいた
今回もコードを自力で読んで気づいたわけじゃなくて、Codex(別の AI)に第三者レビューを走らせた結果 で出てきた指摘。
レビューを走らせた時点で「今日が 2026年5月14日 の場合、2026年6〜12月も『写真なし』として完了扱いにできます。未来月はまだ写真がなくて当然なので、現在月までに制限し、UI側も存在しない月を合成しない方がよいです」と具体的に出てきた。
第三者レビューはコストかかるけど、「今日が何月か」みたいな実行時のコンテキストを踏まえた指摘は、コード読みだと取りこぼしやすいです。
元のコード: 「1〜12月」を機械的に回していた
問題のコードは2箇所にあった。
① 走査側(写真の有無をチェック)
Future<List<(int year, int month)>> scanEmptyMonthsInYear({
required int year,
required Map<String, String> visitStatuses,
}) async {
final permission = await PhotoManager.requestPermissionExtend();
if (!permission.isAuth && !permission.hasAccess) return [];
final result = <(int, int)>[];
for (var m = 1; m <= 12; m++) { // ← 1〜12月を機械的に回す
final key = '$year-${m.toString().padLeft(2, '0')}';
if (visitStatuses[key] == PeriodVisitStatusValue.completed) continue;
final hasPhotos = await hasAnyPhotosInYearMonth(year: year, month: m);
if (!hasPhotos) {
result.add((year, m));
}
}
return result;
}② UI 側(スロットの合成)
期間選択画面では、写真があった月だけじゃなくて「写真はないけど綴れる可能性のある月」もスロットとして並べて、ユーザーが選べるようにしています。これも同じく 1〜12 をループしていた。
final monthMap = {for (final month in months) month.month: month};
final slots = <(int, YearMonthOption)>[];
for (var i = 1; i <= 12; i++) { // ← ここも 1〜12 で機械的に合成
final key = '$year-${i.toString().padLeft(2, '0')}';
if (visitStatuses[key] == PeriodVisitStatusValue.completed) continue;
slots.add((i, monthMap[i] ?? YearMonthOption(year, i)));
}今日が 2026-05-14 のとき、2026年で動かすとこうなります。
修正: 「現在月まで」を境界に持ち込む
直し方は素直に「年が現在年なら現在月まで、過去年なら 12月まで、未来年なら走査しない」に分岐します。
final base = now ?? DateTime.now();
final endMonth = year < base.year
? 12 // 過去年 → 12月まで全部走査
: year == base.year
? base.month // 今年 → 今月まで(先の未来月は見ない)
: 0; // 未来年 → 0(走査しない)
for (var m = 1; m <= endMonth; m++) {
// ...既存ロジック
}UI 側も同じロジックでスロットを止めます。
片方だけ直すと、「UI には出るがスキャンされない月」という半端な状態が出ます。
endMonth = 0 で「ループ実行されない」を表現するイディオム
未来年(2027年など)は endMonth = 0 にしています。for (var m = 1; m <= 0; m++) は 1 回も実行されないので、自然に「走査しない」が表現できます。Dart に限らず C 系の for ループ全般で使える手。
DateTime? now を引数で受け取る理由
scanEmptyMonthsInYear には DateTime? now を引数として足してあります。これはテスタビリティのため。
「今日が何月か」をハードコードした DateTime.now() で実装すると、ユニットテストで「今日が 12月のとき」「今日が 1月のとき」を試すのに OS の時計をいじる必要が出ます(これはかなり面倒)。引数で受け取れるようにしておくと、テストでは now: DateTime(2026, 12, 1) のように好きな日付を渡せます。
既存の呼び出し元は何も変えなくていいので、後方互換も壊しません。
学んだこと
- 「1〜12月を全部見る」みたいな単純なループは境界条件を見落としやすいです。「今日より未来」を含むかどうかは特に意識しないと抜けます
- 走査側と UI 側で同じ境界条件を共有します。片方だけ直すと、「スキャンはされないけど UI には出る」「UI には出ないけどスキャンされてしまう」みたいな半端な状態が出ます。コメントでお互いを明示するのが安全です
DateTime.now()を直接呼ぶ関数はテスタビリティが落ちます。DateTime? nowを引数で受け取って、デフォルトをDateTime.now()にする小さなパターンは横展開しやすいです- 第三者レビュー(Codex)は「今日が何月か」みたいな実行時コンテキストを踏まえた指摘を出してくれるので、コード読みでは取りこぼしやすい類の罠を拾えます
おわりに(エミュレータでの確認で気づいた別の罠)
修正自体は簡単だったけど、エミュレータで動作確認した時に別の罠を踏みかけた。
「写真なし月を隠す」を押すと 全部の月が消えた という事象が起きた。コードの修正は正しかったので「何か別の問題?」と追ったら、エミュレータに adb push で突っ込んだ画像が EXIF を持っていなかった ことが原因だった。
これは今回の P1-2 のスコープ外で、進捗メモには別タスク(P3「hasAnyPhotosInYearMonth のメタデータ依存問題」)として残した。エミュレータで動作確認するときは、push する画像の EXIF をちゃんと整えてから入れる か、実機で確認する のが安全です。
この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。