From fec912fbdacd54821d196e0c30848084231d824e Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 1 Nov 2026 23:27:09 +0100 Subject: [PATCH] feat(smart-albums): index On This Day by month and day On This Day filtered on MONTH() and DAY() of taken_at or created_at, so every query (cover, Album::head header count and offset, photo list) scanned the photos table. photos.month_day_key holds the local day where the photo was taken (as taken_key), else the upload day, and is indexed. It is written explicitly in the Save and SaveIfDirty pipes, PhotoController::update and lychee:takedate, and filled for existing photos by the migration. Spec impact: Feature 106 spec, plan, tasks and open questions (Q-106-01 A), roadmap and knowledge map entries." --- .../Photo/Pipes/Duplicate/SaveIfDirty.php | 4 + app/Actions/Photo/Pipes/Shared/Save.php | 4 + .../Commands/ImageProcessing/Takedate.php | 3 + .../Controllers/Gallery/PhotoController.php | 3 + app/Models/Photo.php | 1 + app/Services/MonthDayKey.php | 33 +++++++ app/SmartAlbums/OnThisDayAlbum.php | 16 ++-- database/factories/PhotoFactory.php | 6 +- ..._01_000001_add_month_day_key_to_photos.php | 89 +++++++++++++++++ .../106-on-this-day-index/open-questions.md | 29 ++++++ .../features/106-on-this-day-index/plan.md | 87 +++++++++++++++++ .../features/106-on-this-day-index/spec.md | 72 ++++++++++++++ .../features/106-on-this-day-index/tasks.md | 60 ++++++++++++ docs/specs/4-architecture/knowledge-map.md | 1 + docs/specs/4-architecture/roadmap.md | 1 + .../Feature_v3/Album/AlbumCategoryV3Test.php | 8 +- tests/Feature_v3/Album/OnThisDayAlbumTest.php | 96 +++++++++++++++++++ tests/Feature_v3/Photo/PhotoKeysWriteTest.php | 72 +++++++++++++- tests/Unit/Services/MonthDayKeyTest.php | 53 ++++++++++ 19 files changed, 619 insertions(+), 19 deletions(-) create mode 100644 app/Services/MonthDayKey.php create mode 100644 database/migrations/2026_11_01_000001_add_month_day_key_to_photos.php create mode 100644 docs/specs/4-architecture/features/106-on-this-day-index/open-questions.md create mode 100644 docs/specs/4-architecture/features/106-on-this-day-index/plan.md create mode 100644 docs/specs/4-architecture/features/106-on-this-day-index/spec.md create mode 100644 docs/specs/4-architecture/features/106-on-this-day-index/tasks.md create mode 100644 tests/Feature_v3/Album/OnThisDayAlbumTest.php create mode 100644 tests/Unit/Services/MonthDayKeyTest.php diff --git a/app/Actions/Photo/Pipes/Duplicate/SaveIfDirty.php b/app/Actions/Photo/Pipes/Duplicate/SaveIfDirty.php index 8058d63bc69..96640508125 100644 --- a/app/Actions/Photo/Pipes/Duplicate/SaveIfDirty.php +++ b/app/Actions/Photo/Pipes/Duplicate/SaveIfDirty.php @@ -10,12 +10,16 @@ use App\Contracts\PhotoCreate\DuplicatePipe; use App\DTO\PhotoCreate\DuplicateDTO; +use App\Services\MonthDayKey; use Illuminate\Support\Facades\Log; class SaveIfDirty implements DuplicatePipe { public function handle(DuplicateDTO $state, \Closure $next): DuplicateDTO { + // FR-106-03: HydrateMetadata may have rewritten `taken_at`. + $state->photo->month_day_key = MonthDayKey::of($state->photo->taken_at, $state->photo->created_at); + // Adopt settings of duplicated photo acc. to target album if ($state->photo->isDirty()) { Log::notice(__METHOD__ . ':' . __LINE__ . ' Updating metadata of existing photo.'); diff --git a/app/Actions/Photo/Pipes/Shared/Save.php b/app/Actions/Photo/Pipes/Shared/Save.php index 37bf73c251c..bb8b018f6f5 100644 --- a/app/Actions/Photo/Pipes/Shared/Save.php +++ b/app/Actions/Photo/Pipes/Shared/Save.php @@ -11,6 +11,7 @@ use App\Contracts\PhotoCreate\PhotoDTO; use App\Contracts\PhotoCreate\PhotoPipe; use App\Services\BucketKeys; +use App\Services\MonthDayKey; use App\Services\TitleSplitter; /** @@ -25,6 +26,9 @@ public function handle(PhotoDTO $state, \Closure $next): PhotoDTO $state->getPhoto()->title_index = $title_split->index; // FR-092-02: assigned at every write of `title`. $state->getPhoto()->title_date_key = BucketKeys::titleDateKey($state->getPhoto()->title ?? ''); + // FR-106-03: assigned at every write of `taken_at` or `created_at`; a + // new photo is stamped with the current time by save(). + $state->getPhoto()->month_day_key = MonthDayKey::of($state->getPhoto()->taken_at, $state->getPhoto()->created_at ?? now()); $state->getPhoto()->save(); $state->getPhoto()->tags()->sync($state->getTags()->pluck('id')->all()); diff --git a/app/Console/Commands/ImageProcessing/Takedate.php b/app/Console/Commands/ImageProcessing/Takedate.php index 5c79222d802..c7d909b14a4 100644 --- a/app/Console/Commands/ImageProcessing/Takedate.php +++ b/app/Console/Commands/ImageProcessing/Takedate.php @@ -16,6 +16,7 @@ use App\Metadata\Extractor; use App\Models\Photo; use App\Services\BucketKeys; +use App\Services\MonthDayKey; use Illuminate\Console\Command; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\App; @@ -230,6 +231,8 @@ public function handle(): int // FR-092-01, FR-092-10: assigned at every write of `taken_at`, // then every `photo_album` key of the photo is recomputed. $photo->taken_key = BucketKeys::takenKey($photo->taken_at); + // FR-106-03: follows `taken_at` and `created_at` alike. + $photo->month_day_key = MonthDayKey::of($photo->taken_at, $photo->created_at); $photo->save(); RecomputePhotoBucketsJob::dispatchIf($photo->wasChanged(['taken_at', 'taken_key', 'created_at']), $photo->id); } diff --git a/app/Http/Controllers/Gallery/PhotoController.php b/app/Http/Controllers/Gallery/PhotoController.php index d8a77fcb9f5..f4404d567ab 100644 --- a/app/Http/Controllers/Gallery/PhotoController.php +++ b/app/Http/Controllers/Gallery/PhotoController.php @@ -58,6 +58,7 @@ use App\Policies\PhotoPolicy; use App\Repositories\ConfigManager; use App\Services\BucketKeys; +use App\Services\MonthDayKey; use App\Services\Telemetry\TraceService; use App\Services\TitleSplitter; use Illuminate\Routing\Controller; @@ -232,6 +233,8 @@ public function update(EditPhotoRequest $request): PhotoResource // if the request takenAt is null, then we set the initial value back. $photo->taken_at = $request->takenAt() ?? $photo->initial_taken_at; $photo->taken_key = BucketKeys::takenKey($photo->taken_at); + // FR-106-03: follows the taken date, else the upload date set above. + $photo->month_day_key = MonthDayKey::of($photo->taken_at, $photo->created_at); // Feature 082: the manual 360° flag, left untouched when not sent (v7). $photo->is_360 = $request->is360() ?? $photo->is_360; diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 464bbdde1bb..feed5007c6b 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -57,6 +57,7 @@ * @property int $title_index * @property string|null $title_date_key Leading date of the title as `Y-m-d` (FR-092-02). * @property string|null $taken_key `taken_at` in `taken_at_orig_tz` as `Y-m-d-H` (FR-092-01). + * @property int|null $month_day_key Month × 100 + day of `taken_key`, else of `created_at` (FR-106-01). * @property string|null $description * @property Collection $tags * @property int $owner_id diff --git a/app/Services/MonthDayKey.php b/app/Services/MonthDayKey.php new file mode 100644 index 00000000000..b1b00fc68ea --- /dev/null +++ b/app/Services/MonthDayKey.php @@ -0,0 +1,33 @@ +format('n') * 100 + (int) $date->format('j'); + } +} diff --git a/app/SmartAlbums/OnThisDayAlbum.php b/app/SmartAlbums/OnThisDayAlbum.php index 892625a35b3..2e23b2dc180 100644 --- a/app/SmartAlbums/OnThisDayAlbum.php +++ b/app/SmartAlbums/OnThisDayAlbum.php @@ -11,6 +11,7 @@ use App\Enum\SmartAlbumType; use App\Exceptions\ConfigurationKeyMissingException; use App\Exceptions\Internal\FrameworkException; +use App\Services\MonthDayKey; use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidTimeZoneException; use Illuminate\Database\Eloquent\Builder; @@ -32,15 +33,14 @@ protected function __construct() parent::__construct( id: SmartAlbumType::ON_THIS_DAY, + // `month_day_key` is the day where the photo was taken, else its + // upload day (FR-106-01), read through its index; an upload of + // this year does not count. smart_condition: function (Builder $query) use ($today): void { - $query->where(fn (Builder $q) => $q - ->whereMonth('photos.taken_at', '=', $today->month) - ->whereDay('photos.taken_at', '=', $today->day)) - ->orWhere(fn (Builder $q) => $q - ->whereNull('photos.taken_at') - ->whereYear('photos.created_at', '<', $today->year) - ->whereMonth('photos.created_at', '=', $today->month) - ->whereDay('photos.created_at', '=', $today->day)); + $query->where('photos.month_day_key', '=', MonthDayKey::ofDate($today)) + ->where(fn (Builder $q) => $q + ->whereNotNull('photos.taken_at') + ->orWhere('photos.created_at', '<', $today->year . '-01-01 00:00:00')); } ); } diff --git a/database/factories/PhotoFactory.php b/database/factories/PhotoFactory.php index c0d17f371f8..6c1d15cb53a 100644 --- a/database/factories/PhotoFactory.php +++ b/database/factories/PhotoFactory.php @@ -15,6 +15,7 @@ use App\Models\Statistics; use App\Models\Tag; use App\Services\BucketKeys; +use App\Services\MonthDayKey; use Database\Factories\Traits\OwnedBy; use Illuminate\Database\Eloquent\Factories\Factory; @@ -195,10 +196,11 @@ public function with_title(string $title): self public function configure(): self { return $this->afterMaking(function (Photo $photo) { - // Row-intrinsic bucket keys (FR-092-01, FR-092-02), as every - // write path assigns them. + // Row-intrinsic bucket keys (FR-092-01, FR-092-02) and the On + // This Day key (FR-106-01), as every write path assigns them. $photo->taken_key = BucketKeys::takenKey($photo->taken_at); $photo->title_date_key = BucketKeys::titleDateKey($photo->title); + $photo->month_day_key = MonthDayKey::of($photo->taken_at, $photo->created_at ?? now()); })->afterCreating(function (Photo $photo) { Statistics::factory()->with_photo($photo->id)->create(); $photo->fresh(); diff --git a/database/migrations/2026_11_01_000001_add_month_day_key_to_photos.php b/database/migrations/2026_11_01_000001_add_month_day_key_to_photos.php new file mode 100644 index 00000000000..9705bb2887f --- /dev/null +++ b/database/migrations/2026_11_01_000001_add_month_day_key_to_photos.php @@ -0,0 +1,89 @@ +optimize = new OptimizeTables(); + } + + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('photos', function (Blueprint $table) { + $this->optimize->dropIndexIfExists($table, 'photos_month_day_key_index'); + }); + + Schema::table('photos', function (Blueprint $table) { + if (!Schema::hasColumn('photos', 'month_day_key')) { + $table->unsignedSmallInteger('month_day_key')->nullable()->default(null)->after('taken_key'); + } + }); + + [$from_taken_key, $from_created_at] = match (DB::getDriverName()) { + 'sqlite' => [ + 'CAST(SUBSTR(taken_key, 6, 2) || SUBSTR(taken_key, 9, 2) AS INTEGER)', + "CAST(strftime('%m%d', created_at) AS INTEGER)", + ], + 'pgsql' => [ + 'CAST(SUBSTR(taken_key, 6, 2) || SUBSTR(taken_key, 9, 2) AS INTEGER)', + "CAST(to_char(created_at, 'MMDD') AS INTEGER)", + ], + 'mysql', 'mariadb' => [ + 'CAST(CONCAT(SUBSTR(taken_key, 6, 2), SUBSTR(taken_key, 9, 2)) AS UNSIGNED)', + "CAST(DATE_FORMAT(created_at, '%m%d') AS UNSIGNED)", + ], + default => throw new \RuntimeException('Unsupported database driver'), + }; + + DB::table('photos') + ->whereNull('month_day_key') + ->whereNotNull('taken_key') + ->update(['month_day_key' => DB::raw($from_taken_key)]); + + DB::table('photos') + ->whereNull('month_day_key') + ->whereNull('taken_key') + ->update(['month_day_key' => DB::raw($from_created_at)]); + + Schema::table('photos', function (Blueprint $table) { + $table->index('month_day_key', 'photos_month_day_key_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $this->optimize->dropIndexIfExists($table, 'photos_month_day_key_index'); + }); + + Schema::table('photos', function (Blueprint $table) { + $table->dropColumn('month_day_key'); + }); + } +}; diff --git a/docs/specs/4-architecture/features/106-on-this-day-index/open-questions.md b/docs/specs/4-architecture/features/106-on-this-day-index/open-questions.md new file mode 100644 index 00000000000..da4c3f41661 --- /dev/null +++ b/docs/specs/4-architecture/features/106-on-this-day-index/open-questions.md @@ -0,0 +1,29 @@ +# Open Questions – Feature 106 + +Open questions for [Feature 106](spec.md). Log every high- and medium-impact question here (table row + Question Details entry) before asking the user; see [open-questions-format.md](../../spec-guidelines/open-questions-format.md). Once answered, fold the outcome into [spec.md](spec.md) (and an ADR when architecturally significant), then mark the entry resolved. + +## Active Questions + +| Question ID | Feature | Priority | Summary | Status | Opened | Updated | +|-------------|---------|----------|---------|--------|--------|---------| +| ~~Q-106-01~~ | 106 | Medium | Day of a photo with a taken date: A local day where it was taken (as `taken_key`, Timeline), B UTC day as stored (today's result) | Resolved (A, FR-106-01, S-106-05) | 2026-11-01 | 2026-11-01 | + +## Question Details + +### ~~Q-106-01~~ · Which day a photo with a taken date belongs to + +**Status:** Resolved (A, owner, 2026-11-01): the key holds the local day where the photo was taken (FR-106-01, S-106-05). +**Preferred option:** A – the local day where the photo was taken + +**Question** +`taken_at` is stored in UTC with the zone it was taken in (`taken_at_orig_tz`). On This Day matches the month and day of the stored UTC value, while `taken_key` (Timeline, Feature 092) uses the local date where the photo was taken. For a photo taken near midnight away from UTC the two days differ: a photo taken at 00:30 on 2 November in Paris is stored as 1 November 23:30 UTC and is shown On This Day on 1 November. Which day does the key hold? + +**Option A (recommended) – the local day where it was taken** +- Idea: key = month and day of `taken_key` (`Y-m-d-H` in `taken_at_orig_tz`); the backfill reads it from the stored `taken_key`. +- Pros: the day the photographer sees on the photo and in the Timeline; one rule across Lychee; the backfill needs no time-zone arithmetic in SQL. +- Cons: photos taken near midnight away from UTC move to the neighbouring day compared with today. + +**Option B – the UTC day as stored** +- Idea: key = month and day of the stored `taken_at`, as the current query. +- Pros: exactly today's members. +- Cons: keeps the off-by-one day for photos taken near midnight; differs from the Timeline. diff --git a/docs/specs/4-architecture/features/106-on-this-day-index/plan.md b/docs/specs/4-architecture/features/106-on-this-day-index/plan.md new file mode 100644 index 00000000000..941a963a5bc --- /dev/null +++ b/docs/specs/4-architecture/features/106-on-this-day-index/plan.md @@ -0,0 +1,87 @@ +# Feature Plan 106 – On This Day Index + +_Linked specification:_ [spec.md](spec.md) +_Linked tasks:_ [tasks.md](tasks.md) +_Status:_ Implemented +_Last updated:_ 2026-11-01 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in the feature's [open-questions.md](open-questions.md), and assume clarifications are resolved only when the spec’s normative sections (requirements/NFR/behaviour/telemetry) and, where applicable, ADRs under `docs/specs/6-decisions/` have been updated. + +## Vision & Success Criteria +On This Day reads only the photos of the day through `photos_month_day_key_index` (FR-106-01, FR-106-02, NFR-106-01). Every date write keeps the key current (FR-106-03), the migration fills it for existing photos (FR-106-04). Success: S-106-01 … 08 green, the SQLite plan names the index, and the scratch instance (50,000 photos) shows `Album::head?album_id=on_this_day` and the On This Day cover no longer scanning `photos`. + +## Scope Alignment +- **In scope:** `App\Services\MonthDayKey`, migration (column, index, backfill), `OnThisDayAlbum`, the four write sites, `PhotoFactory`, `Photo` docblock, knowledge map. +- **Out of scope:** spec Non-Goals (Recent, random header pick, smart-album cover cache). + +## Dependencies & Interfaces +- `photos.taken_key` (Feature 092): the backfill reads the local day where the photo was taken from it; `BucketKeys::takenKey()` formats the same cast `taken_at` the new helper reads. +- `Shared\Save`, `Duplicate\SaveIfDirty`, `PhotoController::update`, `lychee:takedate`: the places that already assign `taken_key`. +- `OptimizeTables::dropIndexIfExists()` for the index, as in `2026_10_12_000001_add_bucket_keys_to_photos`. + +## Assumptions & Risks +- **Assumptions:** `taken_key` is set for every photo with a `taken_at` (assigned at every write since Feature 092, backfilled by its migration). The cast `taken_at` is in `taken_at_orig_tz`; `created_at` and `Carbon::today()` are in the application time zone. +- **Risks / Mitigations:** a single-column index misleading the MySQL planner (2022 migration) — the column is only compared for equality, never sorted on (NFR-106-02). A new photo has no `created_at` before `save()` — the `Save` pipe passes `now()` for a photo without one, the time Eloquent stamps it with. + +## Implementation Drift Gate +2026-11-01, agent, after T-106-08 (all tasks checked, gate green). + +| Requirement | Code | Tests / evidence | +|-------------|------|------------------| +| FR-106-01 | `App\Services\MonthDayKey`, `photos.month_day_key` + `photos_month_day_key_index` | `MonthDayKeyTest` 4/4 (red first) | +| FR-106-02 | `OnThisDayAlbum` smart condition | `OnThisDayAlbumTest` 5/5 (S-106-05 red first); `AlbumCategoryV3Test` 20/20, `AlbumHeadEndpointTest` 15/15 | +| FR-106-03 | `Shared\Save`, `Duplicate\SaveIfDirty`, `PhotoController::update()`, `Takedate`, `PhotoFactory` | `PhotoKeysWriteTest` 12/12 (6 new cases red first), `TakeDateTest` 2/2, `ImportFromServerTest` 6/6 | +| FR-106-04 | `2026_11_01_000001_add_month_day_key_to_photos` | Scratch copy, 50,000 photos (2,000 without taken date, 2,000 taken in Europe/Paris at 23:30 UTC): migration 84 ms, 0 rows without key, 0 keys differing from `MonthDayKey`, the 2,000 Paris photos on their local day | +| NFR-106-01 | – | SQLite plan: `SEARCH photos USING INDEX photos_month_day_key_index (month_day_key=?)`. Same data, old vs new condition: header `count` 20.8 → 3.8 ms, `OFFSET` 12.7 → 2.9 ms, `exists` 2.3 → 1.4 ms; `Album::head?album_id=on_this_day` 37–47 → 17–21 ms (SQL 22–35 → 6 ms); On This Day queries of `Albums/smart` 0.2–1 ms | +| NFR-106-02 | no `orderBy` on the column | code review | +| NFR-106-03 | per-driver backfill expressions (SQLite, PostgreSQL, MySQL/MariaDB) | SQLite run locally; PostgreSQL and MySQL/MariaDB through CI | + +Commands rerun: `php artisan test --filter=` `MonthDayKeyTest`, `OnThisDayAlbumTest`, `AlbumCategoryV3Test`, `AlbumHeadEndpointTest`, `OverridePermissionsTest`, `CoverageTest`, `SmartAlbumTypeTest`, `RecomputeAlbumUserThumbsJobTest`, `AlbumFactoryTest`, `SetSmartProtectionPolicyTest`, `PhotoKeysWriteTest`, `TakeDateTest`, `ImportFromServerTest`, `Photo360UploadTest`, `Photo360EditTest`, `PhotoTitleSyncTest`, `PhotoSortingBucketDispatchTest`, `TimelineAlbumTest` (all green); `php-cs-fixer` on the 13 touched files (0 fixed); `make phpstan` (no errors). Scratch scripts: `onthisday/prepare.php`, `verify.php`, `ab.php`; `cachecheck/head.php`, `smart.php`. + +Findings: `AlbumCategoryV3Test` fixtures moved `taken_at` with raw SQL and now set `taken_key` and `month_day_key` with it. The remaining cold cost of `Albums/smart` on the scratch data is Untagged (128 ms), Recent (120 ms) and Unsorted (53 ms), each scanning `photos` (Non-Goals, follow-up below). + +## Increment Map +1. **I1 – Key helper** (T-106-01, T-106-02) + - _Steps:_ failing `MonthDayKeyTest`, then `App\Services\MonthDayKey`. + - _Commands:_ `php artisan test --filter=MonthDayKeyTest`, `make phpstan`. + - _Exit:_ green, phpstan clean. +2. **I2 – Schema** (T-106-03) + - _Steps:_ migration (column, index, two backfill `UPDATE`s per driver), `PhotoFactory` `afterMaking`, `Photo` docblock. + - _Commands:_ `php artisan test --filter=PhotoKeysWriteTest` (migrates the test database), `make phpstan`. + - _Exit:_ test database migrated, existing tests green. +3. **I3 – On This Day query** (T-106-04, T-106-05) + - _Steps:_ failing `OnThisDayAlbumTest` (S-106-05 fails on the current UTC-day query), then `OnThisDayAlbum` on `month_day_key`. + - _Commands:_ `php artisan test --filter=OnThisDayAlbumTest`, `php artisan test --filter=AlbumHeadEndpointTest`, `php artisan test --filter=AlbumCategoryV3Test`. + - _Exit:_ green. +4. **I4 – Write sites** (T-106-06, T-106-07) + - _Steps:_ failing `PhotoKeysWriteTest` cases (upload, edit taken date, reset, edit upload date, takedate), then the four write sites. + - _Commands:_ `php artisan test --filter=PhotoKeysWriteTest`, `php artisan test --filter=TakeDateTest`, `vendor/bin/php-cs-fixer fix`, `make phpstan`. + - _Exit:_ green. +5. **I5 – Verification and docs** (T-106-08) + - _Steps:_ knowledge map, roadmap; scratch instance: migrate a copy, `EXPLAIN QUERY PLAN`, timings before/after. + - _Exit:_ index used, timings recorded in the drift gate. + +## Scenario Tracking + +| Scenario ID | Increment / Task reference | Notes | +|-------------|---------------------------|-------| +| S-106-01 … 05 | I3 / T-106-04, T-106-05 | `OnThisDayAlbumTest` (time frozen) | +| S-106-06 | I4 / T-106-06, T-106-07 | `PhotoKeysWriteTest` upload | +| S-106-07 | I4 / T-106-06, T-106-07 | `PhotoKeysWriteTest` edit | +| S-106-08 | I4 / T-106-06, T-106-07 | `PhotoKeysWriteTest` takedate | + +## Analysis Gate +2026-11-01, agent: +- Specification completeness: pass — FR-106-01 … 04, NFR-106-01 … 03, S-106-01 … 08; no UI change (no mock-up). +- Open questions: pass — Q-106-01 resolved A; no ADR (one derived column, same pattern as `taken_key`). +- Plan alignment: pass — success criteria match the Goals. +- Tasks coverage: pass — every FR has a task, tests staged before code (T-106-01, 04, 06). +- Working agreements: pass — explicit writes, no model events; migration approved by the owner (option C); no dependency; no migration test (backfill checked on the scratch instance). +- Tooling: pass — commands per increment. + +## Follow-ups / Backlog +- Untagged, Recent and Unsorted still scan `photos` for their cover and header (`Albums/smart` cold, `Album::head`); empty smart albums recompute their cover on every request (`CachesAlbumUserThumb` caches only a photo). + +## Exit Criteria +- T-106-01 … 08 checked; listed test classes green; `php-cs-fixer`, `make phpstan` clean. +- Knowledge map and roadmap updated, drift gate recorded. diff --git a/docs/specs/4-architecture/features/106-on-this-day-index/spec.md b/docs/specs/4-architecture/features/106-on-this-day-index/spec.md new file mode 100644 index 00000000000..6c30fe5ed16 --- /dev/null +++ b/docs/specs/4-architecture/features/106-on-this-day-index/spec.md @@ -0,0 +1,72 @@ +# Feature 106 – On This Day Index + +| Field | Value | +|-------|-------| +| Status | Implemented | +| Last updated | 2026-11-01 | +| Owners | ildyria | +| Linked plan | [plan.md](plan.md) | +| Linked tasks | [tasks.md](tasks.md) | +| Roadmap entry | #106 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in the feature's [open-questions.md](open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/6-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview +The On This Day smart album (`OnThisDayAlbum`) selects photos with `MONTH(taken_at) = m AND DAY(taken_at) = d`, or for photos without `taken_at` the same on `created_at` before the current year. No index can serve these predicates, so every On This Day query reads the whole `photos` table and runs the viewer's visibility filter on the candidates: the album listing cover (`GET /api/v3/Albums/smart`), `Album::head` (frame check, header `exists`, header `count` + `OFFSET`), and the photo listing. On a production instance `Album::head?album_id=on_this_day` takes 10 s and `Albums/smart` up to 7 s, on the first request as on every later one. A stored, indexed month-day key on `photos` lets these queries read only the photos of the day. + +## Goals +- On This Day queries read the matching photos through an index instead of scanning `photos`. +- Every write of `taken_at` or `created_at` on a photo keeps the key current. +- Existing photos get their key when the migration runs. + +## Non-Goals +- Recent (`created_at >= …`, no index on `photos.created_at`) and the other smart albums. +- The random header pick (`HasHeaderUrl`: `count` + `OFFSET`) and the smart-album cover cache: unchanged, they become cheap because their On This Day query is indexed. +- Which photos On This Day shows, beyond the day rule of FR-106-01. + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|--------| +| FR-106-01 | `photos.month_day_key` (unsigned small integer, `month × 100 + day`, nullable) holds the month and day where the photo was taken (the date of `taken_key`: `taken_at` in `taken_at_orig_tz`) when `taken_at` is set, else the month and day of `created_at` as stored. Index `photos_month_day_key_index`. | Taken 2026-03-07 → `307`; taken 2026-11-02 00:30 in Europe/Paris (stored 2026-11-01 23:30 UTC) → `1102`. | – | – | – | Owner choice (index the day, option C), Q-106-01 A | +| FR-106-02 | `OnThisDayAlbum` selects `photos.month_day_key = ` and (`photos.taken_at IS NOT NULL` or `photos.created_at < <1 January of the current year>`), today in the application time zone. | Members follow the day rule of FR-106-01. | – | – | – | Owner choice | +| FR-106-03 | The key is assigned explicitly next to every write of `taken_at` or `created_at`: the shared `Save` pipe and `Duplicate\SaveIfDirty` (upload, import, re-upload), `PhotoController::update` (taken date, upload date), `lychee:takedate`. No model event or mutator. | Key follows the edited date. | – | – | – | Owner directive (explicit writes) | +| FR-106-04 | A migration adds the column and index and fills the key of every existing photo, from `taken_key` when it is set and from `created_at` otherwise: two `UPDATE` statements with driver-specific expressions, only on rows whose key is `NULL`. | Every photo has a key after migrating. | – | – | – | Owner choice | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-106-01 | On This Day queries use `photos_month_day_key_index`; `Album::head?album_id=on_this_day` and `Albums/smart` no longer grow with the size of the library. | 10 s / 7 s in production. | SQLite `EXPLAIN QUERY PLAN` names the index; scratch instance with 50,000 photos timed before and after. | – | Owner report | +| NFR-106-02 | The column is only ever read by an equality predicate, never sorted on, so it cannot draw the MySQL planner away from the `(album_id, …)` indices (rationale of `2022_04_06_091900_drop_objectionable_indices`). | Planner stability. | Code review: no `orderBy` on the column. | – | – | +| NFR-106-03 | MySQL/MariaDB, PostgreSQL and SQLite. | Supported databases. | CI matrix. | – | – | + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|--------------------------------| +| S-106-01 | Photo taken on today's month and day in an earlier year → listed. | +| S-106-02 | Photo taken on another day → not listed. | +| S-106-03 | Photo without taken date uploaded on today's month and day in an earlier year → listed; uploaded on today's date this year → not listed. | +| S-106-04 | Photo taken on another day but uploaded on today's month and day → not listed (the taken date wins). | +| S-106-05 | Photo taken shortly after midnight local time on today's date in a zone east of UTC (stored on the previous day in UTC) → listed today, not on the previous day. | +| S-106-06 | Upload or import with and without an EXIF date → key set from the taken date, else the upload date. | +| S-106-07 | Edit the taken date, or reset it to the initial one → key follows; edit the upload date of a photo without taken date → key follows. | +| S-106-08 | `lychee:takedate` rewrites `taken_at` or `created_at` → key follows. | + +## Test Strategy +- **Unit:** the key helper (dates in and out of UTC, leap day, no taken date). +- **REST (Feature_v3):** On This Day membership S-106-01 … 05 through the v3 photo listing; S-106-07 through `PATCH Photo`. +- **Actions / commands:** S-106-06 through the upload pipeline, S-106-08 through `lychee:takedate` (existing test classes extended). +- **Migration:** no test of its own; the backfill is checked on the scratch instance. + +## Interface & Contract Catalogue + +### Domain Objects +| ID | Description | Modules | +|----|-------------|---------| +| DO-106-01 | `photos.month_day_key` (`unsigned smallint`, nullable), index `photos_month_day_key_index` | models, migrations | + +## Documentation Deliverables +- Knowledge map: `photos.month_day_key` next to `taken_key`. +- Roadmap row. diff --git a/docs/specs/4-architecture/features/106-on-this-day-index/tasks.md b/docs/specs/4-architecture/features/106-on-this-day-index/tasks.md new file mode 100644 index 00000000000..17609ae1b60 --- /dev/null +++ b/docs/specs/4-architecture/features/106-on-this-day-index/tasks.md @@ -0,0 +1,60 @@ +# Feature 106 Tasks – On This Day Index + +_Status: Complete_ +_Last updated: 2026-11-01_ + +> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). +> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. +> When referencing requirements, keep feature IDs (`F-`), non-goal IDs (`N-`), and scenario IDs (`S--`) inside the same parentheses immediately after the task title (omit categories that do not apply). +> When new high- or medium-impact questions arise during execution, add them to the feature's [open-questions.md](open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections (requirements/NFR/behaviour/telemetry) and, when required, ADRs under `docs/specs/6-decisions/` reflect the clarified behaviour. + +## Checklist +- [x] T-106-01 – Failing `MonthDayKeyTest` (FR-106-01). + _Intent:_ Unit tests for the key: taken date in its own zone (Paris after midnight), no taken date → upload date, 29 February, 31 December. + _Verification commands:_ + - `php artisan test --filter=MonthDayKeyTest` (red) + +- [x] T-106-02 – `App\Services\MonthDayKey` (FR-106-01). + _Intent:_ `of(?DateTimeInterface $taken_at, DateTimeInterface $created_at): int`. + _Verification commands:_ + - `php artisan test --filter=MonthDayKeyTest` + - `make phpstan` + +- [x] T-106-03 – Migration, factory, model docblock (FR-106-01, FR-106-04). + _Intent:_ `photos.month_day_key` + `photos_month_day_key_index`, backfill from `taken_key` / `created_at` (two `UPDATE`s per driver); `PhotoFactory` `afterMaking` assigns the key; `@property` on `Photo`. + _Verification commands:_ + - `php artisan test --filter=PhotoKeysWriteTest` + - `make phpstan` + +- [x] T-106-04 – Failing `OnThisDayAlbumTest` (FR-106-02, S-106-01, S-106-02, S-106-03, S-106-04, S-106-05). + _Intent:_ Feature_v3, time frozen; S-106-05 fails on the current UTC-day query. + _Verification commands:_ + - `php artisan test --filter=OnThisDayAlbumTest` (red on S-106-05) + +- [x] T-106-05 – `OnThisDayAlbum` on `month_day_key` (FR-106-02, NFR-106-02). + _Intent:_ `month_day_key = today` and (`taken_at IS NOT NULL` or `created_at < 1 January`). + _Verification commands:_ + - `php artisan test --filter=OnThisDayAlbumTest` + - `php artisan test --filter=AlbumHeadEndpointTest` + - `php artisan test --filter=AlbumCategoryV3Test` + +- [x] T-106-06 – Failing write-site cases in `PhotoKeysWriteTest` (FR-106-03, S-106-06, S-106-07, S-106-08). + _Intent:_ upload, edit taken date, reset to the initial taken date, edit upload date without taken date, `lychee:takedate`. + _Verification commands:_ + - `php artisan test --filter=PhotoKeysWriteTest` (red) + +- [x] T-106-07 – Assign the key at the four write sites (FR-106-03). + _Intent:_ `Shared\Save`, `Duplicate\SaveIfDirty`, `PhotoController::update`, `Takedate`. + _Verification commands:_ + - `php artisan test --filter=PhotoKeysWriteTest` + - `php artisan test --filter=TakeDateTest` + - `vendor/bin/php-cs-fixer fix` + - `make phpstan` + +- [x] T-106-08 – Knowledge map, roadmap, scratch verification (NFR-106-01). + _Intent:_ knowledge map entry next to `taken_key`; migrate a copy of the 50,000-photo scratch database, `EXPLAIN QUERY PLAN` of the On This Day query, `Album::head` and `Albums/smart` timings before and after; drift gate in the plan. + _Verification commands:_ + - scratchpad `cachecheck/head.php on_this_day`, `cachecheck/smart.php` + +## Notes / TODOs +- No migration test (backfill checked on the scratch instance). diff --git a/docs/specs/4-architecture/knowledge-map.md b/docs/specs/4-architecture/knowledge-map.md index 146060ea3f2..f2e7940057a 100644 --- a/docs/specs/4-architecture/knowledge-map.md +++ b/docs/specs/4-architecture/knowledge-map.md @@ -67,6 +67,7 @@ This document tracks modules, dependencies, and architectural relationships acro - **`ColumnSortingAlbumType::OWNER_ID` removal** (Feature 062, FR-062-08, G5) - Deleted outright (a Feature 060 dropdown narrowing had already hidden it from `configs.type_range` without removing the enum case). Migration `2026_09_02_120000_remove_owner_id_album_sorting.php` rewrites surviving `owner_id` values (`configs.sorting_albums_col`, `albums.album_sorting_col`) to `created_at`. `ColumnSortingType::OWNER_ID` (broader, internal enum — used for `Top::queryRootAlbums()`'s hardcoded sort and root's live `shared`-scope `ORDER BY owner_id`) is untouched (NG7). - **`photo_album.bucket_key`** (Feature 064 as `bucket_id`; renamed and redefined by Feature 092, FR-092-03) - Nullable string + composite `(album_id, bucket_key)` index on the **pivot table** — a photo linked into two albums with different effective sort columns carries one key per pivot row. Granularity-free key for the **containing album's own** effective photo sort column: `photos.taken_key`, `created_at` as stored to the hour, `title_date_key` or the first 255 characters of `title_base`, `1`/`0`, `type`, rounded `rating_avg`; `NULL` for `OWNER_ID` (NG11) or missing data. The bucket `buckets` tier reads this table alone (FR-092-09), with an `is_validated` subquery for non-admins. - **`photos.taken_key` / `photos.title_date_key`** (Feature 092, FR-092-01/02, ADR-092-02) - Row-intrinsic keys, assigned explicitly (no Eloquent hook) at every write of `taken_at` (`HydrateMetadata`, `PhotoController::update()`, `lychee:takedate`) and of `title` (`Save` pipe, `update()`/`rename()`, `RenamePhotos`) through `App\Services\BucketKeys`. `taken_key` is `taken_at` in `taken_at_orig_tz` as `Y-m-d-H` (indexed; the Timeline buckets on it); `title_date_key` is the leading `YYYY[-MM[-DD]]` of the title as `Y-m-d`, `NULL` unless a calendar date. `PhotoFactory` derives both in `afterMaking`. + - **`photos.month_day_key`** (Feature 106, FR-106-01 … 03) - Month × 100 + day of the photo's local taken date (the date of `taken_key`), else of `created_at`, through `App\Services\MonthDayKey`; index `photos_month_day_key_index`. The On This Day smart album filters on it (`month_day_key = today`, plus an upload before this year for photos without taken date), so its cover, header and photo queries read only the day's photos. Assigned explicitly next to every write of `taken_at` or `created_at`: `Shared\Save` and `Duplicate\SaveIfDirty` pipes, `PhotoController::update()`, `lychee:takedate`; `PhotoFactory` in `afterMaking`. Only compared for equality, never sorted on. - **`photo_title_bucket_mode`/`photo_title_bucket_prefix_length`** (Feature 064, G6) - Photo-specific instance-wide configs governing `TITLE`-sorted photo bucketing; deliberately independent from albums' own `title_bucket_mode`/`title_bucket_prefix_length` (Feature 061) — never shared, per explicit user direction. - **Services** (`app/Services/`) - Business logic and orchestration - **TemporaryLinkSigner** (`app/Services/TemporaryLinkSigner.php`, Feature 056) - `sign(int $timestamp): string`/`verify(int $timestamp, string $mac): bool`; stateless HMAC-SHA256 of the timestamp only (not `photo_id`/`size_variant`-scoped), keyed by `config('app.key')`, `hash_equals()` comparison. TTL/future-timestamp checks live in the caller (`GetPhotoAssetRequest`), not here. diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index e16a7d5ce50..ac5e14be1ab 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,6 +6,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | Progress | |------------|------|--------|----------|----------|---------|---------|----------| +| 106 | On This Day Index | Implemented | High | ildyria | 2026-11-01 | 2026-11-01 | 8/8 tasks, drift gate in plan. `Album::head?album_id=on_this_day` 10 s, `Albums/smart` up to 7 s in production: On This Day filtered on `MONTH()`/`DAY()`, so every query scanned `photos`. Indexed `photos.month_day_key` (local taken day, else upload day, Q-106-01 A) written at every date write, backfilled by migration (owner choice C). Scratch 50,000 photos: header count 20.8 → 3.8 ms, `Album::head` SQL 22–35 → 6 ms. PostgreSQL/MySQL through CI. Not committed (owner commits). See `features/106-on-this-day-index/`. | | 105 | Album Edit Select Feedback | Testing | Medium | ildyria | 2026-11-01 | 2026-11-01 | 20/20 tasks, drift gate in plan. v8 album edit drawer: "Default" entry for unset fields, sort order follows its column, "Automatic" placeholder on header/cover, timeline `default` shown as "Enabled" (Q-105-01 A, Q-105-02 A); `PATCH /api/v2/Album` stores album sort `min_taken_at`/`max_taken_at` (FR-105-01); header and cover entries show their thumbnail through `Thumb` on struct-of-arrays albums (FR-105-08); "Set as header" and the webshop buy action load a detail-pending photo's details first (FR-105-09); bulk album edit table and fields dialog get the "Default" entry and never send a sort column without its order (FR-105-10, Q-105-03 A). Browser-checked on the scratch instance. Not committed (owner commits). See `features/105-album-edit-select-feedback/`. | | 103 | Album Load Speedup | In progress | High | ildyria | 2026-10-28 | 2026-10-28 | 26/27 tasks, drift gate in plan; v8 bulk album edit: icon headers with label tooltips, capture range tooltip on the creation date (FR-103-13 … 15, Q-103-05 A, v8 only); Q-103-04 C: stored `albums.is_recursive_nsfw` (ADR-103-01, FR-103-11/12), Timeline `buckets` 756 → 408 ms on identical data (battery run; NFR-103-03 AC check pending). Large albums, Timeline and the v8 photo view on struct-of-arrays. Backend: `Album::head`/HTML page read frame flag and header without loading photos (FR-103-01/02, `count` + `offset`, Q-103-03 B); smart-album queries scoped inside the id subquery, `buckets` by `COUNT(DISTINCT)` (FR-103-09). v8: lazy tile dates, navigation ids at adaptation, one-write page map, index-based layout, cached scrubber keys, one placeholder layout per bucket size (FR-103-03 … 06, 10); photo view loading/error state, video mounted with its URL, details window 10/5 and image preload 3/1 (FR-103-07/08). Scratch instance: album 7,000 photos first tile ≈1,070 → ≈656 ms, `head` 198 → 46 ms; Timeline 99,000 photos first tile ≈1,760 → ≈936 ms, window 620 → 24 ms. Not committed (owner commits). See `features/103-album-load-speedup/`. | | 102 | Asset Endpoint Speedup | Testing | Medium | ildyria | 2026-10-20 | 2026-10-29 | Speed up `GET /api/v3/Asset`. Baseline: warm ≈9.2 ms and 15 queries per request, 11 of them from loading the album. No SE verification on the route (FR-102-01). Q-102-01 … 03 resolved Option A (FR-102-02 … 04), Q-102-04 resolved Option A (FR-102-05/06, ADR-102-01). Q-102-05 resolved Option A (FR-102-07, ADR-102-02), Q-102-06 resolved Option A (admins skip album checks, FR-102-02). Plan + 19 tasks, analysis gate passed, I1–I4 done (admin `/Asset` 4 queries incl. partial config, no SE verification; `Cache-Control: private, max-age=3600` + `304`; `GET /Albums/{album_id}/Photos/urls`; tile URL loader; grid tiles on direct URLs; `ETag` on `/Asset`; front-end stores cleared on login/logout, Q-102-11 resolved Option B, FR-102-08). 32/32 tasks, browser check and drift gate done; Q-102-07 open (admin temporary-link rule in `/Photos/urls`). See `features/102-asset-endpoint-speedup/`. | diff --git a/tests/Feature_v3/Album/AlbumCategoryV3Test.php b/tests/Feature_v3/Album/AlbumCategoryV3Test.php index 94141dc5706..c269d8161a8 100644 --- a/tests/Feature_v3/Album/AlbumCategoryV3Test.php +++ b/tests/Feature_v3/Album/AlbumCategoryV3Test.php @@ -72,7 +72,7 @@ public function testSmartReturnsSameSetAsV2(): void public function testSmartWithEveryCoverCachedRunsOnlyDateDependentValidityQueries(): void { $this->travelTo(new \DateTimeImmutable('2026-10-03 12:00:00')); - DB::table('photos')->where('id', '=', $this->photoUnsorted->id)->update(['taken_at' => '2020-10-03 12:00:00', 'created_at' => '2026-10-03 11:00:00']); + DB::table('photos')->where('id', '=', $this->photoUnsorted->id)->update(['taken_at' => '2020-10-03 12:00:00', 'taken_key' => '2020-10-03-12', 'month_day_key' => 1003, 'created_at' => '2026-10-03 11:00:00']); $ids = $this->actingAs($this->userMayUpload1)->getJsonV3('Albums/smart')->assertOk()->json('ids'); self::assertContains(SmartAlbumType::ON_THIS_DAY->value, $ids); @@ -131,8 +131,8 @@ public function testSmartCacheMissResolvesLiveAndSeedsCache(): void public function testSmartOnThisDayStaleCoverIsReplacedByTodaysPhoto(): void { $this->travelTo(new \DateTimeImmutable('2026-10-03 12:00:00')); - DB::table('photos')->update(['taken_at' => '2020-05-01 12:00:00']); - DB::table('photos')->where('id', '=', $this->photo1->id)->update(['taken_at' => '2020-10-03 12:00:00']); + DB::table('photos')->update(['taken_at' => '2020-05-01 12:00:00', 'taken_key' => '2020-05-01-12', 'month_day_key' => 501]); + DB::table('photos')->where('id', '=', $this->photo1->id)->update(['taken_at' => '2020-10-03 12:00:00', 'taken_key' => '2020-10-03-12', 'month_day_key' => 1003]); $this->seedSmartRow(SmartAlbumType::ON_THIS_DAY, $this->photoUnsorted->id); $json = $this->actingAs($this->userMayUpload1)->getJsonV3('Albums/smart')->assertOk()->json(); @@ -148,7 +148,7 @@ public function testSmartOnThisDayStaleCoverIsReplacedByTodaysPhoto(): void public function testSmartOnThisDayStaleCoverWithoutPhotoTodayIsDropped(): void { $this->travelTo(new \DateTimeImmutable('2026-10-03 12:00:00')); - DB::table('photos')->update(['taken_at' => '2020-05-01 12:00:00']); + DB::table('photos')->update(['taken_at' => '2020-05-01 12:00:00', 'taken_key' => '2020-05-01-12', 'month_day_key' => 501]); $this->seedSmartRow(SmartAlbumType::ON_THIS_DAY, $this->photoUnsorted->id); $json = $this->actingAs($this->userMayUpload1)->getJsonV3('Albums/smart')->assertOk()->json(); diff --git a/tests/Feature_v3/Album/OnThisDayAlbumTest.php b/tests/Feature_v3/Album/OnThisDayAlbumTest.php new file mode 100644 index 00000000000..7dbec08cde1 --- /dev/null +++ b/tests/Feature_v3/Album/OnThisDayAlbumTest.php @@ -0,0 +1,96 @@ +travelTo(new \DateTimeImmutable('2026-11-01 12:00:00')); + // The fixture photos are taken now: move them to another day. + DB::table('photos')->update(['taken_at' => '2020-05-01 12:00:00', 'taken_key' => '2020-05-01-12', 'month_day_key' => 501]); + } + + /** + * @param array $attributes + */ + private function photo(array $attributes): Photo + { + return Photo::factory()->owned_by($this->admin)->in($this->album1)->create($attributes); + } + + /** + * @return string[] + */ + private function members(): array + { + $response = $this->actingAs($this->admin)->getJsonWithData('Album::photos', ['album_id' => SmartAlbumType::ON_THIS_DAY->value]); + $this->assertOk($response); + + return $response->json('photos.*.id'); + } + + public function testTakenOnTodaysDateInAnEarlierYearIsListed(): void + { + $photo = $this->photo(['taken_at' => Carbon::create(2019, 11, 1, 15, 0, 0, 'UTC')]); + + self::assertSame([$photo->id], $this->members()); + } + + public function testTakenOnAnotherDayIsNotListed(): void + { + $this->photo(['taken_at' => Carbon::create(2019, 11, 2, 15, 0, 0, 'UTC')]); + + self::assertSame([], $this->members()); + } + + public function testUploadedOnTodaysDateWithoutTakenDateIsListedFromAnEarlierYearOnly(): void + { + $earlier = $this->photo(['taken_at' => null, 'initial_taken_at' => null, 'created_at' => '2024-11-01 09:00:00']); + $this->photo(['taken_at' => null, 'initial_taken_at' => null, 'created_at' => '2026-11-01 09:00:00']); + + self::assertSame([$earlier->id], $this->members()); + } + + public function testTakenDateWinsOverUploadDate(): void + { + $this->photo(['taken_at' => Carbon::create(2019, 5, 5, 15, 0, 0, 'UTC'), 'created_at' => '2024-11-01 09:00:00']); + + self::assertSame([], $this->members()); + } + + public function testTakenAfterMidnightInItsOwnZoneIsListedOnThatDay(): void + { + // 1 November 00:30 in Paris, stored as 31 October 23:30 UTC. + $photo = $this->photo(['taken_at' => Carbon::create(2019, 11, 1, 0, 30, 0, 'Europe/Paris'), 'taken_at_orig_tz' => 'Europe/Paris']); + + self::assertSame([$photo->id], $this->members()); + } +} diff --git a/tests/Feature_v3/Photo/PhotoKeysWriteTest.php b/tests/Feature_v3/Photo/PhotoKeysWriteTest.php index 46b617b5271..6ba2ed4a4e0 100644 --- a/tests/Feature_v3/Photo/PhotoKeysWriteTest.php +++ b/tests/Feature_v3/Photo/PhotoKeysWriteTest.php @@ -22,7 +22,9 @@ use App\Jobs\RecomputePhotoBucketsJob; use App\Models\Photo; use App\Models\RenamerRule; +use App\Repositories\ConfigManager; use App\Services\BucketKeys; +use App\Services\MonthDayKey; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; use Tests\Constants\TestConstants; @@ -31,7 +33,9 @@ /** * Feature 092 (FR-092-01, FR-092-02, FR-092-10, S-092-04, S-092-13): every * write of `taken_at` assigns `photos.taken_key` and every write of `title` - * assigns `photos.title_date_key`, through the real write path. + * assigns `photos.title_date_key`, through the real write path. Feature 106 + * (FR-106-03, S-106-06 … 08): every write of `taken_at` or `created_at` + * assigns `photos.month_day_key`. */ class PhotoKeysWriteTest extends BaseApiWithDataTest { @@ -42,12 +46,17 @@ public function setUp(): void } private function uploadNightImage(string $title): Photo + { + return $this->uploadImage(TestConstants::SAMPLE_FILE_NIGHT_IMAGE, $title); + } + + private function uploadImage(string $file, string $title): Photo { $response = $this->actingAs($this->admin)->upload('Photo', data: [ 'album_id' => $this->album5->id, - 'file' => static::createUploadedFile(TestConstants::SAMPLE_FILE_NIGHT_IMAGE), + 'file' => static::createUploadedFile($file), 'file_last_modified_time' => 1678824303000, - 'file_name' => TestConstants::SAMPLE_FILE_NIGHT_IMAGE, + 'file_name' => $file, 'uuid_name' => '', 'extension' => '', 'chunk_number' => 1, @@ -59,7 +68,7 @@ private function uploadNightImage(string $title): Photo return Photo::query()->findOrFail($response->json('expected_id')); } - private function patchPhoto(?string $taken_at, string $title = 'Holiday'): void + private function patchPhoto(?string $taken_at, string $title = 'Holiday', string $upload_date = '2021-01-01'): void { $response = $this->actingAs($this->userMayUpload1)->patchJson('Photo', [ 'photo_id' => $this->photo1->id, @@ -68,7 +77,7 @@ private function patchPhoto(?string $taken_at, string $title = 'Holiday'): void 'tags' => [], 'license' => 'none', 'taken_at' => $taken_at, - 'upload_date' => '2021-01-01', + 'upload_date' => $upload_date, 'from_id' => $this->album1->id, ]); $this->assertOk($response); @@ -84,6 +93,56 @@ public function testUploadAssignsBothKeys(): void $this->assertSame('2024-03-01', $photo->title_date_key); } + public function testUploadAssignsMonthDayKeyOfTheTakenDate(): void + { + $photo = $this->uploadNightImage('Night'); + + $this->assertNotNull($photo->taken_at); + $this->assertSame(MonthDayKey::ofDate($photo->taken_at), $photo->month_day_key); + } + + public function testUploadWithoutTakenDateAssignsMonthDayKeyOfTheUploadDate(): void + { + $this->travelTo(new \DateTimeImmutable('2026-03-07 12:00:00')); + $previous = resolve(ConfigManager::class)->getValueAsString('use_last_modified_date_when_no_exif_date'); + \Configs::set('use_last_modified_date_when_no_exif_date', false); + try { + $photo = $this->uploadImage(TestConstants::SAMPLE_FILE_PNG, 'Drawing'); + } finally { + \Configs::set('use_last_modified_date_when_no_exif_date', $previous); + } + + $this->assertNull($photo->taken_at); + $this->assertSame(307, $photo->month_day_key); + } + + public function testUpdateAssignsMonthDayKeyOfTheLocalDay(): void + { + // 22:30 UTC on 30 June, taken at UTC+02:00 → 1 July where it was taken. + $this->patchPhoto('2026-07-01T00:30:00+02:00'); + + $this->assertSame(701, $this->photo1->month_day_key); + } + + public function testUpdateResetToInitialTakenAtAssignsMonthDayKey(): void + { + DB::table('photos')->where('id', '=', $this->photo1->id)->update(['initial_taken_at' => '2019-05-05 12:00:00', 'initial_taken_at_orig_tz' => 'UTC']); + + $this->patchPhoto(null); + + $this->assertSame(505, $this->photo1->month_day_key); + } + + public function testUpdateUploadDateWithoutTakenDateAssignsMonthDayKey(): void + { + DB::table('photos')->where('id', '=', $this->photo1->id)->update(['taken_at' => null, 'initial_taken_at' => null, 'taken_key' => null]); + + $this->patchPhoto(null, upload_date: '2021-03-07'); + + $this->assertNull($this->photo1->taken_at); + $this->assertSame(307, $this->photo1->month_day_key); + } + public function testUpdateAssignsTakenKeyInCaptureZone(): void { // 14:05 UTC taken at UTC+02:00 → hour 16 in the zone the photo was taken in. @@ -167,12 +226,14 @@ public function testTakedateAssignsTakenKeyAndRecomputes(): void $photo = $this->uploadNightImage('Night'); $expected_key = $photo->taken_key; $this->assertNotNull($expected_key); + $expected_month_day_key = MonthDayKey::ofDate($photo->taken_at); // Without --force the command only visits photos without `taken_at`: // every fixture photo has one, so only this photo is processed. DB::table('photos')->where('id', '=', $photo->id)->update([ 'taken_at' => null, 'taken_key' => null, + 'month_day_key' => null, ]); Bus::fake([RecomputePhotoBucketsJob::class]); @@ -180,6 +241,7 @@ public function testTakedateAssignsTakenKeyAndRecomputes(): void $photo->refresh(); $this->assertSame($expected_key, $photo->taken_key); + $this->assertSame($expected_month_day_key, $photo->month_day_key); Bus::assertDispatched(RecomputePhotoBucketsJob::class, fn (RecomputePhotoBucketsJob $job) => $job->photo_id === $photo->id); } } diff --git a/tests/Unit/Services/MonthDayKeyTest.php b/tests/Unit/Services/MonthDayKeyTest.php new file mode 100644 index 00000000000..31c4b1fba88 --- /dev/null +++ b/tests/Unit/Services/MonthDayKeyTest.php @@ -0,0 +1,53 @@ +