From 49e758ecb45d493153200b5cebc6b2c96e0f6ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20L=2E?= Date: Sun, 14 Jun 2026 23:32:02 +0100 Subject: [PATCH] begin update tag counts --- .../Commands/Tags/RebuildTagCounts.php | 71 ++++++ .../API/Tags/GetTagsController.php | 122 ++++++++++ config/tags.php | 35 +++ readme/API.md | 38 ++- readme/ArtisanCommands.md | 1 + readme/changelog/2026-06-14.md | 18 ++ resources/data/tag_counts_public.json | 123 ++++++++++ resources/data/tag_usage_counts.json | 126 ++++++++++ routes/api.php | 1 + tests/Feature/Api/Tags/MostTaggedTest.php | 92 ++++++++ tests/Feature/Api/Tags/TagUsageCountsTest.php | 63 +++++ .../Tags/RebuildTagCountsCommandTest.php | 218 ++++++++++++++++++ 12 files changed, 906 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/Tags/RebuildTagCounts.php create mode 100644 config/tags.php create mode 100644 readme/changelog/2026-06-14.md create mode 100644 resources/data/tag_counts_public.json create mode 100644 resources/data/tag_usage_counts.json create mode 100644 tests/Feature/Api/Tags/MostTaggedTest.php create mode 100644 tests/Feature/Api/Tags/TagUsageCountsTest.php create mode 100644 tests/Feature/Tags/RebuildTagCountsCommandTest.php diff --git a/app/Console/Commands/Tags/RebuildTagCounts.php b/app/Console/Commands/Tags/RebuildTagCounts.php new file mode 100644 index 000000000..dc6aa1f99 --- /dev/null +++ b/app/Console/Commands/Tags/RebuildTagCounts.php @@ -0,0 +1,71 @@ += 2 AND is_public — what is visible on the map)}'; + + protected $description = 'Rebuild a committed tag counts JSON (recorded tags per object/category/type). --scope=total writes the internal file, --scope=public writes the public on-map file.'; + + public function handle(): int + { + $scope = $this->option('scope'); + + if (! in_array($scope, ['total', 'public'], true)) { + $this->error("Invalid --scope '{$scope}'. Use 'total' or 'public'."); + + return self::FAILURE; + } + + [$path, $scopeLabel] = $scope === 'public' + ? [config('tags.public_counts_path'), 'verified_public_on_map'] + : [config('tags.usage_counts_path'), 'total_recorded_tags']; + + $this->info('Aggregating tag usage counts...'); + + $query = DB::table('photo_tags as pt') + ->join('photos as p', 'p.id', '=', 'pt.photo_id') + ->whereNull('p.deleted_at') + ->whereNotNull('pt.litter_object_id'); + + if ($scope === 'public') { + $query->where('p.is_public', 1) + ->where('p.verified', '>=', 2); + } + + $rows = $query + ->groupBy('pt.litter_object_id', 'pt.category_id', 'pt.litter_object_type_id') + ->select( + 'pt.litter_object_id', + 'pt.category_id', + 'pt.litter_object_type_id', + DB::raw('COUNT(*) as cnt') + ) + ->get(); + + $counts = []; + + foreach ($rows as $row) { + $key = $row->litter_object_id.':'.$row->category_id.':'.($row->litter_object_type_id ?? 0); + $counts[$key] = (int) $row->cnt; + } + + $payload = [ + 'generated_at' => now()->toDateString(), + 'scope' => $scopeLabel, + 'counts' => $counts, + ]; + + File::ensureDirectoryExists(dirname($path)); + File::put($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); + + $this->info(count($counts).' distinct (object, category, type) keys written to '.$path); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/API/Tags/GetTagsController.php b/app/Http/Controllers/API/Tags/GetTagsController.php index 016084d29..38b4b9b3c 100644 --- a/app/Http/Controllers/API/Tags/GetTagsController.php +++ b/app/Http/Controllers/API/Tags/GetTagsController.php @@ -14,7 +14,9 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; class GetTagsController extends Controller { @@ -86,9 +88,129 @@ public function getAllTags(): JsonResponse 'types' => $types, 'category_objects' => $categoryObjects, 'category_object_types' => $categoryObjectTypes, + 'tag_usage_counts' => $this->loadTagUsageCounts(), ]); } + /** + * Public-facing "most tagged litter" ranking. + * + * Read-only, no auth. Serves the committed public counts file (scoped to + * photos visible on the map: is_public + verified >= 2) as a ranked list at + * coarse object + category granularity — the per-type buckets are summed, + * zero-count pairs hidden, ordered by count descending (ties broken by + * object then category id for a stable, reproducible ranking). Labels are + * resolved client-side from the object/category vocabulary /api/tags/all + * already ships, so only ids and counts are returned here. The read is + * cached on the file's mtime — never a live aggregate query per request. + * Missing or malformed files degrade gracefully to an empty list. + */ + public function getMostTagged(): JsonResponse + { + $payload = $this->loadPublicTagCounts(); + + $coarse = []; + + foreach ($payload['counts'] as $key => $count) { + $parts = explode(':', (string) $key); + + if (count($parts) < 2) { + continue; + } + + $pairKey = $parts[0].':'.$parts[1]; + $coarse[$pairKey] = ($coarse[$pairKey] ?? 0) + (int) $count; + } + + $mostTagged = []; + + foreach ($coarse as $pairKey => $count) { + if ($count <= 0) { + continue; + } + + [$objectId, $categoryId] = explode(':', $pairKey); + + $mostTagged[] = [ + 'object_id' => (int) $objectId, + 'category_id' => (int) $categoryId, + 'count' => $count, + ]; + } + + usort($mostTagged, fn (array $a, array $b) => [$b['count'], $a['object_id'], $a['category_id']] + <=> [$a['count'], $b['object_id'], $b['category_id']]); + + return response()->json([ + 'generated_at' => $payload['generated_at'], + 'scope' => $payload['scope'], + 'most_tagged' => $mostTagged, + ]); + } + + /** + * Load the pre-computed public tag counts payload from the committed JSON + * file, cached on the file's mtime. Missing, empty, or malformed files + * degrade gracefully to an empty payload. + * + * @return array{generated_at: ?string, scope: ?string, counts: array} + */ + protected function loadPublicTagCounts(): array + { + $empty = ['generated_at' => null, 'scope' => null, 'counts' => []]; + + $path = config('tags.public_counts_path'); + + if (! $path || ! File::exists($path)) { + return $empty; + } + + $mtime = File::lastModified($path); + + return Cache::rememberForever("tag_counts_public:{$mtime}", function () use ($path, $empty) { + $decoded = json_decode(File::get($path), true); + + if (! is_array($decoded) || ! isset($decoded['counts']) || ! is_array($decoded['counts'])) { + return $empty; + } + + return [ + 'generated_at' => $decoded['generated_at'] ?? null, + 'scope' => $decoded['scope'] ?? null, + 'counts' => $decoded['counts'], + ]; + }); + } + + /** + * Load the pre-computed tag usage counts map from the committed JSON file. + * + * The file is regenerated by hand via `olm:rebuild-tag-counts`. The read is + * cached on the file's mtime so it happens once until the file changes — we + * never run a live aggregate query per request. Missing, empty, or malformed + * files degrade gracefully to an empty map. + * + * @return array + */ + protected function loadTagUsageCounts(): array + { + $path = config('tags.usage_counts_path'); + + if (! $path || ! File::exists($path)) { + return []; + } + + $mtime = File::lastModified($path); + + return Cache::rememberForever("tag_usage_counts:{$mtime}", function () use ($path) { + $decoded = json_decode(File::get($path), true); + + return is_array($decoded) && isset($decoded['counts']) && is_array($decoded['counts']) + ? $decoded['counts'] + : []; + }); + } + /** * Build a query that filters by available models. */ diff --git a/config/tags.php b/config/tags.php new file mode 100644 index 000000000..c7d89795e --- /dev/null +++ b/config/tags.php @@ -0,0 +1,35 @@ + resource_path('data/tag_usage_counts.json'), + + /* + |-------------------------------------------------------------------------- + | Public Tag Counts File + |-------------------------------------------------------------------------- + | + | Path to the committed JSON file holding the public-facing count of + | recorded tags, scoped to photos visible on the public map + | (`is_public = 1 AND verified >= 2`). Regenerated by hand with + | `php artisan olm:rebuild-tag-counts --scope=public` and read by + | GetTagsController to serve the /api/tags/most-tagged ranking. Kept at the + | same (object, category, type) granularity as the internal file so the two + | are diffable row-for-row. Separate from the internal total so both numbers + | stay transparent and auditable. + | + */ + 'public_counts_path' => resource_path('data/tag_counts_public.json'), +]; diff --git a/readme/API.md b/readme/API.md index 8a6ee4cc5..4c39a2eb2 100644 --- a/readme/API.md +++ b/readme/API.md @@ -357,7 +357,7 @@ Side effects: S3 upload (full + bbox thumbnail), reverse geocoding via `ResolveL **Auth:** None (public) -This is the primary endpoint for building a tag search UI. Returns 7 flat collections that the client must join locally to build a searchable index. +This is the primary endpoint for building a tag search UI. Returns 7 flat collections that the client must join locally to build a searchable index, plus a `tag_usage_counts` map for popularity ranking. **Response (200):** ```json @@ -392,10 +392,17 @@ This is the primary endpoint for building a tag search UI. Returns 7 flat collec { "category_litter_object_id": 42, "litter_object_type_id": 1 }, { "category_litter_object_id": 42, "litter_object_type_id": 2 }, { "category_litter_object_id": 42, "litter_object_type_id": 3 } - ] + ], + "tag_usage_counts": { + "12:2:1": 17195, + "12:2:2": 642, + "5:1:0": 44289 + } } ``` +**`tag_usage_counts`** — all-time count of recorded tags per `(litter_object_id, category_id, litter_object_type_id)`. Key format is `"{object_id}:{category_id}:{type_id}"` where `type_id` is `0` when the tag has no type. Use it to rank objects in a "Most Tagged" browse view and to order a CLO's type picker by popularity. **Scope:** *total recorded tags* — every tag on a non-deleted photo regardless of verification or public status (the truthful tagging-behaviour signal, not on-map verified counts). Sum a CLO's type buckets (e.g. `12:2:*`) to get its per-category total. The map is read from a committed JSON file (`resources/data/tag_usage_counts.json`) regenerated by hand via `php artisan olm:rebuild-tag-counts`; it is cached on the file's mtime, never computed live. Missing/empty file → `{}`. + **How to build a search index from this data:** The 7 collections relate as follows: @@ -433,6 +440,33 @@ When a user selects "wine", you submit `category_litter_object_id: 42, litter_ob --- +### GET /api/tags/most-tagged — Public "Most Tagged Litter" Ranking + +**Auth:** None (public, read-only) + +A public-facing ranking of the most-tagged litter, scoped to what is visible on the public map. Unlike `tag_usage_counts` in `/api/tags/all` (which is the internal *total recorded tags* signal), this figure is scoped to `is_public = 1 AND verified >= 2` — the documented "on the map" standard — so a published OLM number reconciles with the map. The divergence from the internal total is small (~2% across all objects). + +Returns a list ordered by count descending (ties broken by `object_id` then `category_id` for a stable ranking), at coarse **object + category** granularity — the per-type buckets in the underlying file are summed, and zero-count pairs are hidden. Labels are not included: resolve `object_id`/`category_id` to keys client-side using the `objects` and `categories` collections already shipped by `/api/tags/all`. + +**Response:** +```json +{ + "generated_at": "2026-06-14", + "scope": "verified_public_on_map", + "most_tagged": [ + { "object_id": 89, "category_id": 12, "count": 85300 }, + { "object_id": 118, "category_id": 15, "count": 76703 }, + { "object_id": 9, "category_id": 8, "count": 43503 } + ] +} +``` + +Served from a committed JSON file (`resources/data/tag_counts_public.json`) regenerated by hand via `php artisan olm:rebuild-tag-counts --scope=public`; cached on the file's mtime, never computed live. Missing/empty/malformed file → `{ "generated_at": null, "scope": null, "most_tagged": [] }`. + +> **Note (map-visibility predicates, for the record — not a contract):** three endpoints apply different visibility filters. The cluster layer requires `verified >= 2 AND is_public = 1` (this endpoint matches it); a second clustering path uses `verified >= 1 AND is_public = 1`; and `GET /api/points` (`PointsController`) filters `is_public` only, with no `verified` gate. This is a known latent inconsistency, documented here so the full picture lives in one place. + +--- + ### POST /api/v3/tags — Add Tags to Photo **Auth:** Required (Sanctum) diff --git a/readme/ArtisanCommands.md b/readme/ArtisanCommands.md index 30d267693..10d936e80 100644 --- a/readme/ArtisanCommands.md +++ b/readme/ArtisanCommands.md @@ -65,6 +65,7 @@ These live in `tmp/` and are intended for the v5 migration period only. |---------|---------| | `seed:tags` | Run GenerateTagsSeeder (required for test DB setup) | | `tags:verify-for-user-id {user_id}` | Verify remaining tags for a user | +| `olm:rebuild-tag-counts` | Rebuild a committed tag-counts JSON — recorded-tag counts per (object, category, type). `--scope=total` (default/omitted) writes `resources/data/tag_usage_counts.json` (all recorded tags, embedded in `/api/tags/all`); `--scope=public` writes `resources/data/tag_counts_public.json` (scoped to `is_public AND verified >= 2` — on-map photos — served by `/api/tags/most-tagged`). Run by hand (no schedule); commit the regenerated file. | --- diff --git a/readme/changelog/2026-06-14.md b/readme/changelog/2026-06-14.md new file mode 100644 index 000000000..657dfc232 --- /dev/null +++ b/readme/changelog/2026-06-14.md @@ -0,0 +1,18 @@ +# 2026-06-14 + +## Session: Tag usage counts dataset + +- v5.12.4 — Add `olm:rebuild-tag-counts` artisan command: aggregates all-time recorded-tag counts per `(litter_object_id, category_id, litter_object_type_id)` over non-soft-deleted photos (`litter_object_id IS NOT NULL`) into the committed JSON file `resources/data/tag_usage_counts.json` (`{generated_at, scope: "total_recorded_tags", counts}`). No DB table, no schedule, no per-request grouped query — regenerated by hand. +- v5.12.4 — `GetTagsController::getAllTags` now embeds the counts map under `tag_usage_counts` in `/api/tags/all`, read from the committed file and cached on file mtime (degrades gracefully to `{}` when the file is missing/empty). Powers the mobile "Most Tagged" browse view and popularity-ordered type picker. +- v5.12.4 — Add `config/tags.php` (`usage_counts_path`) as the single source of truth for the file path, shared by command (write) and controller (read), overridable in tests. +- v5.12.4 — Tests: `RebuildTagCountsCommandTest` (correct counts, soft-delete exclusion, null type_id → `0` key, extra-tag-only exclusion) and `TagUsageCountsTest` (embedded map, missing-file and empty-file graceful degradation). 7 new tests; full Tags + Api/Tags suites green (117 passed). +- v5.12.4 — Docs: `readme/API.md` (`/api/tags/all` response shape + scope note) and `readme/ArtisanCommands.md` (Tags table). + +## Session: Public most-tagged litter statistic + +- v5.12.5 — Add `--scope` option to `olm:rebuild-tag-counts`: `total` (default/omitted) is byte-for-byte the existing behaviour writing `resources/data/tag_usage_counts.json` (scope `total_recorded_tags`); `public` adds `WHERE is_public = 1 AND verified >= 2` and writes a separate committed file `resources/data/tag_counts_public.json` (scope `verified_public_on_map`) at the same (object, category, type) granularity so the two are diffable row-for-row. Uses the raw query builder's `>= 2` like `ClusteringService` (no `VerificationStatus` enum cast). +- v5.12.5 — Add public, read-only `GET /api/tags/most-tagged`: serves `tag_counts_public.json` as a ranked list at coarse object+category granularity (per-type buckets summed, zero-count pairs hidden, count desc with object/category id tie-break for a stable ranking). Returns `{object_id, category_id, count}` only — labels resolve client-side from the `/api/tags/all` vocabulary. Read cached on file mtime, never a live `GROUP BY`. Missing/empty/malformed file → empty list. Powers a public "Most Tagged Litter" figure that reconciles with the map (~2% below the internal total across all objects). +- v5.12.5 — Add `config/tags.php` `public_counts_path` as the single source of truth for the public file path (writer + reader + tests). +- v5.12.5 — Generated `resources/data/tag_counts_public.json` from the dev DB: 117 (object, category, type) keys, 562,201 on-map tags vs 572,859 total recorded (1.86% scoped out). Top public pairs: plastic/other (85.3k), butts/smoking (76.7k), packaging/food (43.5k), can/softdrinks (37.4k). +- v5.12.5 — Tests: `MostTaggedTest` (ranked coarse list summing types, zero-count exclusion, missing-file and malformed-file graceful degradation) and two new `RebuildTagCountsCommandTest` cases (`--scope=public` filters to `is_public AND verified >= 2`; `--scope=total` leaves the public file untouched). 13 tests green across the Tags + Api/Tags suites. +- v5.12.5 — Docs: `readme/API.md` (new `/api/tags/most-tagged` endpoint) and `readme/ArtisanCommands.md` (`--scope` option). Recorded for the record (not fixed) the map-visibility predicate inconsistency: clusters use `verified >= 2 AND is_public`, a second clustering path uses `verified >= 1 AND is_public`, and `/api/points` filters `is_public` only. diff --git a/resources/data/tag_counts_public.json b/resources/data/tag_counts_public.json new file mode 100644 index 000000000..3eedf39d6 --- /dev/null +++ b/resources/data/tag_counts_public.json @@ -0,0 +1,123 @@ +{ + "generated_at": "2026-06-14", + "scope": "verified_public_on_map", + "counts": { + "1:8:0": 3678, + "1:2:0": 711, + "2:2:1": 7223, + "2:2:2": 639, + "2:16:23": 16814, + "5:2:1": 19163, + "41:8:0": 2585, + "5:16:24": 16332, + "9:8:0": 43503, + "28:6:0": 3447, + "37:15:13": 9729, + "9:2:0": 1607, + "1:16:0": 1033, + "53:8:0": 18791, + "2:16:27": 2917, + "23:5:0": 3705, + "1:12:0": 31096, + "89:12:0": 85300, + "23:16:0": 15841, + "121:15:15": 401, + "118:15:0": 76703, + "2:2:3": 2621, + "2:16:24": 6292, + "8:16:0": 2608, + "6:5:0": 8989, + "4:2:0": 4895, + "47:8:0": 3544, + "76:10:0": 493, + "3:2:0": 3352, + "102:13:0": 1545, + "120:15:0": 502, + "2:16:29": 893, + "106:14:0": 172, + "93:12:0": 27165, + "1:14:0": 6157, + "119:15:0": 827, + "1:5:0": 859, + "91:12:0": 2536, + "71:10:0": 6580, + "70:10:0": 1765, + "74:10:0": 3506, + "111:14:0": 157, + "1:10:0": 4337, + "25:16:0": 7313, + "92:12:0": 8974, + "1:15:0": 3513, + "6:16:0": 14044, + "124:16:29": 796, + "105:14:0": 439, + "69:10:0": 888, + "108:14:0": 69, + "124:16:25": 2197, + "2:16:25": 1834, + "5:16:26": 20486, + "40:8:0": 5036, + "125:16:0": 2100, + "115:14:0": 186, + "45:8:0": 1048, + "43:8:0": 131, + "44:8:0": 2865, + "2:16:28": 828, + "5:16:31": 584, + "9:15:0": 3130, + "94:12:0": 205, + "1:3:0": 39, + "122:15:0": 597, + "1:7:0": 344, + "77:10:0": 3235, + "90:12:0": 116, + "11:2:0": 231, + "88:12:0": 2370, + "129:17:0": 793, + "135:17:0": 182, + "63:10:0": 151, + "112:14:0": 169, + "7:2:0": 64, + "103:13:0": 1460, + "80:11:0": 2373, + "1:9:0": 305, + "79:11:0": 7705, + "58:9:0": 58, + "23:8:0": 58, + "51:8:0": 2390, + "19:12:0": 497, + "29:7:0": 285, + "126:16:0": 1027, + "15:12:0": 925, + "104:14:0": 3285, + "6:2:0": 716, + "10:16:0": 755, + "96:12:0": 1721, + "85:11:0": 41, + "54:9:0": 50, + "123:15:22": 63, + "95:12:0": 1019, + "123:15:17": 479, + "46:8:0": 164, + "59:9:0": 333, + "56:9:0": 25, + "42:8:0": 418, + "3:16:0": 42, + "28:6:7": 3, + "28:6:9": 10, + "28:6:8": 5, + "1:6:0": 1, + "107:14:0": 1, + "2:16:0": 2, + "5:16:0": 2, + "123:15:0": 2, + "50:8:0": 1, + "2:2:0": 2, + "2:2:6": 1, + "73:10:0": 1, + "5:2:3": 3, + "124:16:0": 1, + "36:8:0": 1, + "100:12:0": 1 + } +} diff --git a/resources/data/tag_usage_counts.json b/resources/data/tag_usage_counts.json new file mode 100644 index 000000000..949cf8411 --- /dev/null +++ b/resources/data/tag_usage_counts.json @@ -0,0 +1,126 @@ +{ + "generated_at": "2026-06-14", + "scope": "total_recorded_tags", + "counts": { + "1:8:0": 3752, + "1:2:0": 720, + "2:2:1": 7342, + "2:2:2": 642, + "2:16:23": 17195, + "5:2:1": 19285, + "41:8:0": 2631, + "5:16:24": 16469, + "9:8:0": 44289, + "28:6:0": 3636, + "37:15:13": 9804, + "9:2:0": 1714, + "1:16:0": 1060, + "53:8:0": 19200, + "2:16:27": 2931, + "23:5:0": 3724, + "1:12:0": 33240, + "89:12:0": 85811, + "23:16:0": 16001, + "121:15:15": 408, + "118:15:0": 78060, + "2:2:3": 2665, + "2:16:24": 6451, + "8:16:0": 2640, + "6:5:0": 9062, + "4:2:0": 4907, + "47:8:0": 3656, + "76:10:0": 493, + "3:2:0": 3376, + "102:13:0": 1620, + "120:15:0": 526, + "2:16:29": 901, + "106:14:0": 172, + "93:12:0": 27528, + "1:14:0": 6203, + "119:15:0": 833, + "1:5:0": 861, + "91:12:0": 2632, + "71:10:0": 6696, + "70:10:0": 1923, + "74:10:0": 3511, + "111:14:0": 160, + "1:10:0": 4454, + "25:16:0": 7368, + "92:12:0": 10073, + "1:15:0": 3518, + "6:16:0": 14233, + "124:16:29": 827, + "105:14:0": 456, + "69:10:0": 894, + "108:14:0": 71, + "124:16:25": 2247, + "2:16:25": 1862, + "5:16:26": 20500, + "40:8:0": 5087, + "125:16:0": 2121, + "115:14:0": 190, + "45:8:0": 1086, + "43:8:0": 131, + "44:8:0": 2889, + "2:16:28": 829, + "5:16:31": 588, + "9:15:0": 3284, + "94:12:0": 208, + "1:3:0": 42, + "122:15:0": 603, + "1:7:0": 351, + "77:10:0": 3284, + "90:12:0": 116, + "11:2:0": 236, + "88:12:0": 2420, + "129:17:0": 796, + "135:17:0": 202, + "63:10:0": 151, + "112:14:0": 179, + "7:2:0": 66, + "103:13:0": 1467, + "80:11:0": 2424, + "1:9:0": 328, + "79:11:0": 7742, + "58:9:0": 83, + "23:8:0": 65, + "51:8:0": 2414, + "19:12:0": 517, + "29:7:0": 292, + "126:16:0": 1039, + "15:12:0": 1042, + "104:14:0": 3326, + "6:2:0": 813, + "10:16:0": 758, + "96:12:0": 1730, + "85:11:0": 42, + "54:9:0": 57, + "123:15:22": 63, + "95:12:0": 1022, + "123:15:17": 484, + "46:8:0": 174, + "59:9:0": 345, + "56:9:0": 27, + "42:8:0": 424, + "3:16:0": 43, + "28:6:7": 3, + "28:6:9": 12, + "28:6:8": 7, + "1:6:0": 1, + "107:14:0": 2, + "2:16:0": 2, + "5:16:0": 2, + "124:16:0": 2, + "123:15:0": 2, + "50:8:0": 1, + "2:2:0": 2, + "2:2:6": 1, + "73:10:0": 1, + "4:16:0": 1, + "5:2:3": 3, + "36:8:0": 1, + "100:12:0": 1, + "19:4:0": 1, + "16:4:0": 1 + } +} diff --git a/routes/api.php b/routes/api.php index eec4aaa03..4f9bca7b2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -96,6 +96,7 @@ Route::get('/tags', [GetTagsController::class, 'index']); Route::get('/tags/all', [GetTagsController::class, 'getAllTags']); +Route::get('/tags/most-tagged', [GetTagsController::class, 'getMostTagged']); Route::get('/points', [PointsController::class, 'index']); Route::get('/points/stats', [PointsStatsController::class, 'index']); Route::get('/points/{id}', [PointsController::class, 'show'])->where('id', '[0-9]+'); diff --git a/tests/Feature/Api/Tags/MostTaggedTest.php b/tests/Feature/Api/Tags/MostTaggedTest.php new file mode 100644 index 000000000..3c622752c --- /dev/null +++ b/tests/Feature/Api/Tags/MostTaggedTest.php @@ -0,0 +1,92 @@ +path = sys_get_temp_dir().'/tag_counts_public_'.uniqid().'.json'; + config(['tags.public_counts_path' => $this->path]); + } + + protected function tearDown(): void + { + if (File::exists($this->path)) { + File::delete($this->path); + } + + parent::tearDown(); + } + + public function test_returns_ranked_coarse_list_summing_per_type_buckets(): void + { + File::put($this->path, json_encode([ + 'generated_at' => '2026-06-14', + 'scope' => 'verified_public_on_map', + 'counts' => [ + '5:16:24' => 100, + '5:16:26' => 50, // same object+category, different type → coarse sum of 150 + '9:8:0' => 200, // highest + '1:2:0' => 30, // lowest + ], + ])); + + $response = $this->getJson('/api/tags/most-tagged'); + + $response->assertOk(); + $response->assertJsonPath('scope', 'verified_public_on_map'); + $response->assertJsonPath('generated_at', '2026-06-14'); + $response->assertJsonCount(3, 'most_tagged'); + $response->assertJsonPath('most_tagged.0', ['object_id' => 9, 'category_id' => 8, 'count' => 200]); + $response->assertJsonPath('most_tagged.1', ['object_id' => 5, 'category_id' => 16, 'count' => 150]); + $response->assertJsonPath('most_tagged.2', ['object_id' => 1, 'category_id' => 2, 'count' => 30]); + } + + public function test_zero_count_pairs_are_excluded(): void + { + File::put($this->path, json_encode([ + 'generated_at' => '2026-06-14', + 'scope' => 'verified_public_on_map', + 'counts' => [ + '9:8:0' => 5, + '7:2:0' => 0, // zero → excluded + ], + ])); + + $response = $this->getJson('/api/tags/most-tagged'); + + $response->assertOk(); + $response->assertJsonCount(1, 'most_tagged'); + $response->assertJsonPath('most_tagged.0.object_id', 9); + } + + public function test_missing_file_returns_empty_list(): void + { + $this->assertFalse(File::exists($this->path)); + + $response = $this->getJson('/api/tags/most-tagged'); + + $response->assertOk(); + $response->assertJsonPath('most_tagged', []); + $response->assertJsonPath('scope', null); + $response->assertJsonPath('generated_at', null); + } + + public function test_malformed_file_returns_empty_list(): void + { + File::put($this->path, 'not valid json{'); + + $response = $this->getJson('/api/tags/most-tagged'); + + $response->assertOk(); + $response->assertJsonPath('most_tagged', []); + } +} diff --git a/tests/Feature/Api/Tags/TagUsageCountsTest.php b/tests/Feature/Api/Tags/TagUsageCountsTest.php new file mode 100644 index 000000000..2ca8b367b --- /dev/null +++ b/tests/Feature/Api/Tags/TagUsageCountsTest.php @@ -0,0 +1,63 @@ +path = sys_get_temp_dir().'/tag_usage_counts_'.uniqid().'.json'; + config(['tags.usage_counts_path' => $this->path]); + } + + protected function tearDown(): void + { + if (File::exists($this->path)) { + File::delete($this->path); + } + + parent::tearDown(); + } + + public function test_tags_all_embeds_the_counts_map(): void + { + File::put($this->path, json_encode([ + 'generated_at' => '2026-06-14', + 'scope' => 'total_recorded_tags', + 'counts' => ['5:16:0' => 37559, '5:2:0' => 19288], + ])); + + $response = $this->getJson('/api/tags/all'); + + $response->assertOk(); + $response->assertJsonPath('tag_usage_counts.5:16:0', 37559); + $response->assertJsonPath('tag_usage_counts.5:2:0', 19288); + } + + public function test_missing_file_degrades_to_empty_map(): void + { + $this->assertFalse(File::exists($this->path)); + + $response = $this->getJson('/api/tags/all'); + + $response->assertOk(); + $response->assertJsonPath('tag_usage_counts', []); + } + + public function test_empty_file_degrades_to_empty_map(): void + { + File::put($this->path, ''); + + $response = $this->getJson('/api/tags/all'); + + $response->assertOk(); + $response->assertJsonPath('tag_usage_counts', []); + } +} diff --git a/tests/Feature/Tags/RebuildTagCountsCommandTest.php b/tests/Feature/Tags/RebuildTagCountsCommandTest.php new file mode 100644 index 000000000..e04b3daeb --- /dev/null +++ b/tests/Feature/Tags/RebuildTagCountsCommandTest.php @@ -0,0 +1,218 @@ +outputPath = sys_get_temp_dir().'/tag_usage_counts_'.uniqid().'.json'; + $this->publicPath = sys_get_temp_dir().'/tag_counts_public_'.uniqid().'.json'; + config([ + 'tags.usage_counts_path' => $this->outputPath, + 'tags.public_counts_path' => $this->publicPath, + ]); + } + + protected function tearDown(): void + { + foreach ([$this->outputPath, $this->publicPath] as $path) { + if (File::exists($path)) { + File::delete($path); + } + } + + parent::tearDown(); + } + + /** + * @return array + */ + private function runAndReadCounts(): array + { + $this->artisan('olm:rebuild-tag-counts')->assertExitCode(0); + + return json_decode(File::get($this->outputPath), true)['counts']; + } + + public function test_command_produces_correct_counts(): void + { + $category = Category::factory()->create(); + $object = LitterObject::factory()->create(); + $type = LitterObjectType::factory()->create(); + $photo = Photo::factory()->create(); + + for ($i = 0; $i < 3; $i++) { + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $object->id, + 'litter_object_type_id' => $type->id, + 'quantity' => 1, + ]); + } + + $this->artisan('olm:rebuild-tag-counts')->assertExitCode(0); + + $data = json_decode(File::get($this->outputPath), true); + + $this->assertArrayHasKey('generated_at', $data); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $data['generated_at']); + $this->assertSame('total_recorded_tags', $data['scope']); + $this->assertSame(3, $data['counts']["{$object->id}:{$category->id}:{$type->id}"]); + } + + public function test_soft_deleted_photos_are_excluded(): void + { + $category = Category::factory()->create(); + $object = LitterObject::factory()->create(); + + $livePhoto = Photo::factory()->create(); + $deletedPhoto = Photo::factory()->create(); + + foreach ([$livePhoto, $deletedPhoto] as $photo) { + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $object->id, + 'quantity' => 1, + ]); + } + + $deletedPhoto->delete(); + + $counts = $this->runAndReadCounts(); + + // Only the live photo's tag is counted. + $this->assertSame(1, $counts["{$object->id}:{$category->id}:0"]); + } + + public function test_null_type_id_uses_zero_key(): void + { + $category = Category::factory()->create(); + $object = LitterObject::factory()->create(); + $photo = Photo::factory()->create(); + + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $object->id, + 'litter_object_type_id' => null, + 'quantity' => 1, + ]); + + $counts = $this->runAndReadCounts(); + + $this->assertArrayHasKey("{$object->id}:{$category->id}:0", $counts); + $this->assertSame(1, $counts["{$object->id}:{$category->id}:0"]); + } + + public function test_extra_tag_only_rows_without_object_are_excluded(): void + { + $category = Category::factory()->create(); + $object = LitterObject::factory()->create(); + $photo = Photo::factory()->create(); + + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $object->id, + 'quantity' => 1, + ]); + + // Extra-tag-only tag (brand/material/custom): null litter_object_id, excluded. + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => null, + 'litter_object_id' => null, + 'quantity' => 1, + ]); + + $counts = $this->runAndReadCounts(); + + $this->assertCount(1, $counts); + $this->assertSame(1, $counts["{$object->id}:{$category->id}:0"]); + } + + public function test_public_scope_filters_to_public_and_verified_on_map(): void + { + $category = Category::factory()->create(); + $object = LitterObject::factory()->create(); + + // On the map: is_public AND verified >= 2 (ADMIN_APPROVED and BBOX_APPLIED both qualify). + $approved = Photo::factory()->create([ + 'is_public' => true, + 'verified' => VerificationStatus::ADMIN_APPROVED->value, + ]); + $bbox = Photo::factory()->create([ + 'is_public' => true, + 'verified' => VerificationStatus::BBOX_APPLIED->value, + ]); + + // Excluded: public but not yet on the map (verified < 2). + $awaitingApproval = Photo::factory()->create([ + 'is_public' => true, + 'verified' => VerificationStatus::VERIFIED->value, + ]); + + // Excluded: approved but private (e.g. school or private-by-choice). + $private = Photo::factory()->create([ + 'is_public' => false, + 'verified' => VerificationStatus::ADMIN_APPROVED->value, + ]); + + foreach ([$approved, $bbox, $awaitingApproval, $private] as $photo) { + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $object->id, + 'quantity' => 1, + ]); + } + + $this->artisan('olm:rebuild-tag-counts', ['--scope' => 'public'])->assertExitCode(0); + + $data = json_decode(File::get($this->publicPath), true); + + $this->assertSame('verified_public_on_map', $data['scope']); + // Only the two on-map photos are counted; the private and not-yet-verified ones are dropped. + $this->assertCount(1, $data['counts']); + $this->assertSame(2, $data['counts']["{$object->id}:{$category->id}:0"]); + } + + public function test_total_scope_leaves_public_file_untouched(): void + { + $category = Category::factory()->create(); + $object = LitterObject::factory()->create(); + $photo = Photo::factory()->create(); + + PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $object->id, + 'quantity' => 1, + ]); + + // Omitted scope == total: writes the internal file, never the public one. + $this->artisan('olm:rebuild-tag-counts')->assertExitCode(0); + + $this->assertTrue(File::exists($this->outputPath)); + $this->assertSame('total_recorded_tags', json_decode(File::get($this->outputPath), true)['scope']); + $this->assertFalse(File::exists($this->publicPath)); + } +}