Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
292 commits
Select commit Hold shift + click to select a range
d6929db
remove tag_types
xlcrr Feb 8, 2025
5742169
tagging progress
xlcrr Feb 9, 2025
2b9d752
update tagging
xlcrr Feb 10, 2025
0caa83a
update tagging
xlcrr Feb 10, 2025
c5d3e15
update brands list
xlcrr Feb 12, 2025
a26e33d
update brandslist, seeder
xlcrr Feb 13, 2025
e4f0232
update tags
xlcrr Feb 21, 2025
d4795e9
updte tagging
xlcrr Feb 22, 2025
0fc00fb
tagging progress
xlcrr Feb 23, 2025
f42ce8f
tagging progress
xlcrr Feb 23, 2025
a49759a
try to upgrade laravel 12, not ready yet, removed filament
xlcrr Feb 24, 2025
c901396
update tagging in a big way
xlcrr Feb 24, 2025
5380299
update tagging
xlcrr Feb 25, 2025
c1b3718
fix tests
xlcrr Feb 28, 2025
972f9a1
update tagging
xlcrr Feb 28, 2025
bb30916
update tagging
xlcrr Feb 28, 2025
bcf82ef
update tagging
xlcrr Mar 1, 2025
82ce48c
update tagging
xlcrr Mar 2, 2025
792e8fd
update tags
xlcrr Mar 3, 2025
2f698c3
update tagging
xlcrr Mar 3, 2025
f1355b9
update tagging
xlcrr Mar 4, 2025
b88e7d5
update tagging
xlcrr Mar 6, 2025
fbf4cae
update tagging
xlcrr Mar 8, 2025
1e85d9b
update tagging
xlcrr Mar 9, 2025
d4565ba
update tagging
xlcrr Mar 10, 2025
8c33bb0
update tagging - nested objects
xlcrr Mar 10, 2025
d4684ca
update tagging
xlcrr Mar 11, 2025
306f9cc
begin badges
xlcrr Mar 14, 2025
049f088
create badges
xlcrr Mar 19, 2025
35ab14d
websockets
xlcrr Mar 20, 2025
2f214a3
update tagging, fix websockets on https locally
xlcrr Mar 21, 2025
ceffcd1
update worldcup metadata header
xlcrr Mar 21, 2025
a440135
update world cup, sort locations, components
xlcrr Mar 23, 2025
2b01f8e
prepare for migration
xlcrr Mar 23, 2025
e2912f2
update migration script
xlcrr Mar 23, 2025
3ed9f15
start taggable
xlcrr Mar 24, 2025
d0abf1e
updates
xlcrr Mar 25, 2025
87db924
update pre migration script
xlcrr Mar 27, 2025
7809fb3
tagging updates
xlcrr Mar 27, 2025
aed8617
update tags
xlcrr Mar 28, 2025
0babcdc
update tagging
xlcrr Mar 29, 2025
197332c
update pre-migration script
xlcrr Mar 30, 2025
8286fad
update tagging
xlcrr Mar 30, 2025
02c200c
update migration, transform old to new tags
xlcrr Apr 1, 2025
93eb491
update tagging migration
xlcrr Apr 2, 2025
458bd0b
update tagging - add state, size
xlcrr Apr 3, 2025
a1ece89
update tagging migration
xlcrr Apr 4, 2025
2f0913c
update tags service to migrate old to new tags
xlcrr Apr 5, 2025
e2bef90
update tagging relationships
xlcrr Apr 6, 2025
822c705
classify customTag
xlcrr Apr 6, 2025
521e1d6
update tagging summary
xlcrr Apr 9, 2025
7301979
update tagging tests
xlcrr Apr 10, 2025
7126416
update tagging migration tests
xlcrr Apr 11, 2025
4b93f0d
update tests
xlcrr Apr 12, 2025
9e42e90
pass all tests
xlcrr Apr 13, 2025
d038e1b
add db host to yml
xlcrr Apr 13, 2025
ee23cf9
update
xlcrr Apr 13, 2025
b2bb647
update tagging
xlcrr Apr 13, 2025
57954fc
update to UpdateTagsServiceTest
xlcrr Apr 18, 2025
ed8ede2
update to UpdateTagsServiceTest
xlcrr Apr 18, 2025
b69405c
pass all tests
xlcrr Apr 18, 2025
bba72ca
update assertion
xlcrr Apr 18, 2025
356bb86
update tests remove annoying logs
xlcrr Apr 19, 2025
46534cb
update laravel.yml
xlcrr Apr 19, 2025
058ce6f
update laravel.yml
xlcrr Apr 19, 2025
38317c2
update laravel.yml
xlcrr Apr 19, 2025
6bda2be
update laravel.yml
xlcrr Apr 19, 2025
de16407
Tests
xlcrr Apr 20, 2025
a917ab0
fix tests
xlcrr Apr 20, 2025
cffb831
update yml
xlcrr Apr 20, 2025
1a40d79
update yml
xlcrr Apr 20, 2025
4c76617
update yml with o3
xlcrr Apr 20, 2025
f9f3d2a
update yml
xlcrr Apr 20, 2025
dd8a1b1
update yml
xlcrr Apr 20, 2025
7686d5f
update yml again
xlcrr Apr 20, 2025
608d3da
update yml
xlcrr Apr 20, 2025
6097631
delete deprecated file
xlcrr Apr 20, 2025
099c8d0
delete generate key
xlcrr Apr 20, 2025
831e2e8
-vvv
xlcrr Apr 20, 2025
1e4be88
update -vvv
xlcrr Apr 20, 2025
40b3363
yml
xlcrr Apr 20, 2025
626b6ab
yml again
xlcrr Apr 20, 2025
4562580
update yml
xlcrr Apr 20, 2025
ec1b265
update yml
xlcrr Apr 20, 2025
725feff
update
xlcrr Apr 20, 2025
8a3024d
try again
xlcrr Apr 20, 2025
58e044c
TEST OMG
xlcrr Apr 20, 2025
af6befe
update
xlcrr Apr 20, 2025
007cfa4
remove
xlcrr Apr 20, 2025
e7995dc
generate new app key
xlcrr Apr 20, 2025
393934a
again
xlcrr Apr 20, 2025
8b417fc
Test
xlcrr Apr 20, 2025
05eaa77
update yml
xlcrr Apr 20, 2025
9e19470
update yml
xlcrr Apr 20, 2025
85d0d09
test
xlcrr Apr 20, 2025
e9170a2
more test
xlcrr Apr 20, 2025
59ec8a1
test again
xlcrr Apr 20, 2025
95b139c
another test
xlcrr Apr 20, 2025
b183e2e
pass tests
xlcrr Apr 20, 2025
8329e6a
update app key yml
xlcrr Apr 20, 2025
785c584
update
xlcrr Apr 20, 2025
5b629bb
update yml
xlcrr Apr 20, 2025
2e56321
remove app key
xlcrr Apr 20, 2025
0ae9343
Try again
xlcrr Apr 20, 2025
22253f3
update tests, yml
xlcrr Apr 20, 2025
b87f4f5
add tests
xlcrr Apr 20, 2025
4591bcb
fix
xlcrr Apr 20, 2025
932b1fc
again
xlcrr Apr 20, 2025
f63b6ec
fix
xlcrr Apr 20, 2025
f4b50f2
again
xlcrr Apr 20, 2025
74b6dee
fix again
xlcrr Apr 20, 2025
0ab861f
test again
xlcrr Apr 20, 2025
978f610
fix?
xlcrr Apr 20, 2025
0ce7927
try again
xlcrr Apr 20, 2025
76c23f8
pls work
xlcrr Apr 20, 2025
6c4041f
test again
xlcrr Apr 21, 2025
8f75554
try again
xlcrr Apr 21, 2025
601aad4
remove verbose
xlcrr Apr 21, 2025
c35e212
test img
xlcrr Apr 21, 2025
9dd183e
update services
xlcrr Apr 21, 2025
9bb6c85
add calculate photo test
xlcrr Apr 21, 2025
f60f8b0
add achievements
xlcrr Apr 25, 2025
ddca49d
update redis service
xlcrr Apr 26, 2025
cc62f82
stash
xlcrr Apr 27, 2025
57535e7
update achievements
xlcrr Apr 28, 2025
bd4617c
update achievements
xlcrr Apr 29, 2025
68c1647
update achievements
xlcrr Apr 30, 2025
3e301f4
More efficient and idempotent redis and achievements
xlcrr May 3, 2025
f380264
more updates
xlcrr May 4, 2025
dc40226
update achievement engine
xlcrr May 5, 2025
bc4b206
updates
xlcrr May 6, 2025
4fb1512
update timeseries
xlcrr May 10, 2025
89d6b2d
update timeseries
xlcrr May 10, 2025
e902831
add photo metrics repo
xlcrr May 10, 2025
3bad3a1
add index
xlcrr May 10, 2025
12ae148
update migration script
xlcrr May 10, 2025
03996c6
update tags
xlcrr May 11, 2025
ae9fed5
update tags
xlcrr May 12, 2025
b389dc6
update redis
xlcrr May 13, 2025
1d5f80b
update tagging
xlcrr May 13, 2025
4d0a286
pass tests
xlcrr May 16, 2025
5f040d0
update achievements
xlcrr May 16, 2025
0f7e04b
update achievements
xlcrr May 17, 2025
844c13e
update achievements
xlcrr May 17, 2025
835dff3
bugs
xlcrr May 19, 2025
624594c
nearly there
xlcrr May 20, 2025
e3d9084
update achievements
xlcrr May 21, 2025
4e68dba
stash before refactor
xlcrr May 24, 2025
b7919f2
Claude + o3 conversation refactor
xlcrr May 24, 2025
029d5c0
day 2
xlcrr May 25, 2025
639aade
more updates to achievements
xlcrr May 26, 2025
be0eea5
update achievements
xlcrr May 29, 2025
ec3913e
update achievements
xlcrr May 31, 2025
1958282
update migration script and tests
xlcrr Jun 1, 2025
652b7ae
remove deprecated unit, fix redis test
xlcrr Jun 1, 2025
57b84fb
fix tests,remove deprecated
xlcrr Jun 1, 2025
3385ce6
cleanup
xlcrr Jun 1, 2025
c551ebf
fix unit tests dir
xlcrr Jun 1, 2025
c9dce80
update provider
xlcrr Jun 1, 2025
e075de3
update action
xlcrr Jun 1, 2025
a7e45c4
Merge pull request #674 from OpenLitterMap/claude/achievements-refactor
xlcrr Jun 1, 2025
5f26335
updates
xlcrr Jun 2, 2025
4b0d688
update tests
xlcrr Jun 3, 2025
39f8e39
update tests
xlcrr Jun 4, 2025
1d7f083
pass tests
xlcrr Jun 5, 2025
997fa6c
update redis & cache
xlcrr Jun 7, 2025
f78b5fd
improvements
xlcrr Jun 7, 2025
4913cb5
remove bloom filters
xlcrr Jun 8, 2025
10d2647
use lua script during redis collection
xlcrr Jun 8, 2025
f09c869
use photos or photoIds
xlcrr Jun 8, 2025
5221391
update caching
xlcrr Jun 8, 2025
97c4095
update redis tests
xlcrr Jun 8, 2025
64e5721
update achievement tests
xlcrr Jun 8, 2025
9b41bdf
update migration script
xlcrr Jun 9, 2025
b4cf276
fix last test
xlcrr Jun 11, 2025
38441a4
new achievements frontend, sanctum
xlcrr Jun 11, 2025
8813fee
update clusters weekend 1
xlcrr Jun 22, 2025
e8e33e3
update clusters
xlcrr Jun 27, 2025
ee3d71b
update clusters
xlcrr Jun 28, 2025
756a611
update clustering
xlcrr Jun 29, 2025
f929b5f
fix clustering tests
xlcrr Jun 29, 2025
c6a3484
remove old test
xlcrr Jun 29, 2025
64a4cbc
update clustering
xlcrr Jul 6, 2025
e3987b5
update clusters test
xlcrr Jul 12, 2025
ac731be
update about
xlcrr Jul 13, 2025
2df9717
update about page
xlcrr Jul 17, 2025
378cdd3
update about
xlcrr Jul 19, 2025
4c6bb04
update about, get points
xlcrr Jul 20, 2025
928326c
stash
xlcrr Jul 24, 2025
ef3c476
update points, about, translations
xlcrr Jul 25, 2025
70cb510
update spatial index, points controller, tests
xlcrr Jul 27, 2025
9c5d69e
optimise new points api
xlcrr Jul 28, 2025
60f87f5
refactoring
xlcrr Jul 29, 2025
5d051ed
fix lon,lat order, fix tests
xlcrr Jul 30, 2025
d660caf
fix about maps
xlcrr Jul 30, 2025
6867200
refactor about, langs
xlcrr Jul 31, 2025
eda98a5
update global map
xlcrr Aug 4, 2025
1ba2fb2
update stuff
xlcrr Aug 8, 2025
bb833a4
update create account
xlcrr Aug 9, 2025
48e0f64
update create account, terms & conditions
xlcrr Aug 10, 2025
da8a6c5
update create account, terms & conditions
xlcrr Aug 10, 2025
38f1359
update analyse points
xlcrr Aug 15, 2025
5618318
refactor global map, add stats
xlcrr Aug 16, 2025
03e3923
update location services
xlcrr Aug 23, 2025
968342f
update location services
xlcrr Aug 24, 2025
b976789
refactoring
xlcrr Aug 29, 2025
747d3ca
update everything
xlcrr Aug 30, 2025
4d5912f
more refactoring
xlcrr Aug 31, 2025
f006a02
fix xp tests
xlcrr Sep 6, 2025
83ee21d
fix calc xp + summary tests
xlcrr Sep 6, 2025
6e6b289
fix all tests, refactoring
xlcrr Sep 7, 2025
05db66b
update tagging
xlcrr Sep 13, 2025
9454743
update view uploads, tags
xlcrr Sep 14, 2025
32b05ed
get users photos + tags
xlcrr Sep 21, 2025
23a46d6
update points
xlcrr Sep 28, 2025
4afd3c4
update migration script
xlcrr Oct 3, 2025
e638bf8
tagging updates
xlcrr Oct 4, 2025
40ccb3c
fix map view, debug migration script
xlcrr Oct 5, 2025
580acc6
remove
xlcrr Oct 5, 2025
e730c64
get photo
xlcrr Oct 7, 2025
83812e1
update migration script
xlcrr Oct 12, 2025
a0aad2b
progress?
xlcrr Oct 18, 2025
4adabf0
update migration
xlcrr Oct 22, 2025
3936c11
updates
xlcrr Oct 25, 2025
043f8db
update tags brand migration
xlcrr Oct 26, 2025
796461e
more updates
xlcrr Oct 27, 2025
0e23ca7
more updates
xlcrr Nov 1, 2025
5a50543
update tagging
xlcrr Nov 2, 2025
2e0b4b1
update tags migration
xlcrr Nov 8, 2025
a502016
update tags wen fix
xlcrr Nov 9, 2025
dd46bad
update tags is hard
xlcrr Nov 15, 2025
4d44d5e
remove deprecated tests
xlcrr Nov 15, 2025
ddbf712
update tagging
xlcrr Nov 15, 2025
42f032e
add changelog history
xlcrr Nov 23, 2025
c9af1d9
update tagging
xlcrr Nov 23, 2025
f08115f
update tagging
xlcrr Nov 24, 2025
b1832fa
update add tags section
xlcrr Jan 11, 2026
1dea6f0
bug fixes
xlcrr Feb 13, 2026
eeeb8dd
location migration complete
xlcrr Feb 14, 2026
642ae70
update locations view
xlcrr Feb 15, 2026
f882bd7
update impact controller
xlcrr Feb 15, 2026
debef22
refactor locations, create account, upload process
xlcrr Feb 21, 2026
9790c7e
delete vue2 resources
xlcrr Feb 22, 2026
7a3bc04
keep old js for now
xlcrr Feb 22, 2026
64011ac
Add CLAUDE.md with project conventions and quick reference
xlcrr Feb 23, 2026
619ce2c
Add soft deletes to photos and fix metrics reversal in delete flow
xlcrr Feb 23, 2026
92c64d8
Add 8 custom Laravel Boost skills for domain-specific guidance
xlcrr Feb 23, 2026
e0c7da1
Merge branch 'master' into upgrade/tagging-2025
xlcrr Feb 23, 2026
b2e815a
Update daily summary with soft-delete fix and Boost skills
xlcrr Feb 23, 2026
244125f
Fix 6 tagging bugs, fix LocationController countries, update docs
xlcrr Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Binary file modified .DS_Store
Binary file not shown.
148 changes: 148 additions & 0 deletions .ai/skills/location-system/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
name: location-system
description: Countries, states, cities, ResolveLocationAction, Location base model, LocationType enum, geocoding, and location-level Redis data.
---

# Location System

Location tables store identity only (name, shortcode, FKs). All aggregates live in the `metrics` table and Redis. The `Location` base model computes all stats from Redis on access via `$appends`.

## Key Files

- `app/Models/Location/Location.php` — Abstract base model with Redis-backed computed attributes
- `app/Models/Location/Country.php` — Route key: `shortcode` (ISO 3166-1 alpha-2)
- `app/Models/Location/State.php` — Belongs to Country
- `app/Models/Location/City.php` — Belongs to Country + State
- `app/Actions/Locations/ResolveLocationAction.php` — Lat/lon -> Country/State/City via geocoding
- `app/Actions/Locations/ReverseGeocodeLocationAction.php` — LocationIQ API wrapper
- `app/Actions/Locations/LocationResult.php` — DTO returned by ResolveLocationAction
- `app/Enums/LocationType.php` — Global(0), Country(1), State(2), City(3)
- `app/Enums/Timescale.php` — AllTime(0), Daily(1), Weekly(2), Monthly(3), Yearly(4)

## Invariants

1. **Location tables store identity only.** No `total_*` counters, no `manual_verify`, no aggregates. All stats come from Redis or the `metrics` table.
2. **Photo table uses FK columns only:** `country_id`, `state_id`, `city_id`. Deprecated string columns (`country`, `county`, `city`, `display_name`, `location`, `road`) are dropped.
3. **Redis is a derived cache.** All Redis location data is rebuildable from the `metrics` table.
4. **HyperLogLog for contributor counts.** `PFCOUNT` gives ~0.81% error, O(1) space, append-only (cannot decrement).
5. **Country uses `shortcode` as route key**, not `id`. Routes: `/countries/{shortcode}`.

## Patterns

### ResolveLocationAction

```php
// app/Actions/Locations/ResolveLocationAction.php
public function run(float $lat, float $lon): LocationResult
{
$address = $this->reverseGeocode->run($lat, $lon);

$country = $this->resolveCountry($address); // firstOrCreate by country_code
$state = $this->resolveState($country, $address);
$city = $this->resolveCity($country, $state, $address);

return new LocationResult($country, $state, $city, $address, $displayName);
}
```

**Lookup strategy for city:** Searches keys in order: `city`, `town`, `city_district`, `village`, `hamlet`, `locality`, `county`.

### LocationResult DTO

```php
readonly class LocationResult
{
public function __construct(
public Country $country,
public State $state,
public City $city,
public array $addressArray,
public string $displayName,
) {}
}
```

### LocationType enum

```php
enum LocationType: int
{
case Global = 0; // dbColumn: null, scopePrefix: {g}
case Country = 1; // dbColumn: country_id, scopePrefix: {c:$id}
case State = 2; // dbColumn: state_id, scopePrefix: {s:$id}
case City = 3; // dbColumn: city_id, scopePrefix: {ci:$id}

public function dbColumn(): ?string
public function scopePrefix(int $id = 0): string
public function modelClass(): ?string
public function parentType(): ?self
}
```

### Location model computed attributes (from Redis)

```php
// All appended attributes on Country/State/City models:
$country->total_litter_redis // HGET {c:$id}:stats litter
$country->total_photos_redis // HGET {c:$id}:stats uploads
$country->total_contributors_redis // PFCOUNT {c:$id}:hll
$country->total_xp // HGET {c:$id}:stats xp
$country->litter_data // HGETALL {c:$id}:cat (resolved to names)
$country->objects_data // top 20 from {c:$id}:obj
$country->materials_data // HGETALL {c:$id}:mat
$country->brands_data // HGETALL {c:$id}:brands
$country->ppm // Cached time-series from metrics table (15min TTL)
$country->recent_activity // Last 7 days daily counts (5min TTL)
```

### Location hierarchy rankings

```php
RedisKeys::globalCountryLitterRanking() // {g}:rank:c:litter (ZSET)
RedisKeys::globalCountryPhotosRanking() // {g}:rank:c:photos
RedisKeys::countryStateRanking($countryId, $metric) // {c:$id}:rank:s:$metric
RedisKeys::stateCityRanking($stateId, $metric) // {s:$id}:rank:ci:$metric
```

### Database schema (identity only)

```sql
countries (id, country, shortcode UNIQUE, created_by, timestamps)
states (id, state, country_id, created_by, timestamps, UNIQUE(country_id, state))
cities (id, city, country_id, state_id, created_by, timestamps, UNIQUE(country_id, state_id, city))
```

## LocationController API (v1)

`app/Http/Controllers/Location/LocationController.php` serves the locations browsing UI.

### Endpoints
- `GET /api/v1/locations` — Global view: list of countries with stats
- `GET /api/v1/locations/{type}/{id}` — Drill into country/state/city

### Response keys
```json
{
"stats": { "countries": 120, "states": 450, "cities": 1200, ... },
"locations": [ ... ],
"location_type": "country",
"breadcrumbs": [ ... ],
"activity": [ ... ]
}
```

**Key naming:** Response uses `locations` (not `children`) and `location_type` (not `children_type`). The Pinia store `useLocationsStore` reads these exact keys.

### Time filtering
Supports `?period=today|yesterday|this_month|last_month|this_year` and `?year=2024` query params. Mutually exclusive — year clears period and vice versa.

## Common Mistakes

- **Adding aggregate columns to location tables.** Aggregates live in `metrics` table and Redis. Location tables are identity only.
- **Using deprecated photo string columns.** `country`, `county`, `city`, `display_name`, `location`, `road` are dropped. Use `country_id`, `state_id`, `city_id` FKs.
- **Routing countries by ID instead of shortcode.** Country model has `getRouteKeyName(): 'shortcode'`.
- **Treating Redis location stats as authoritative.** They're derived caches. The `metrics` table is source of truth.
- **Decrementing HyperLogLog.** PFCOUNT is append-only. You cannot remove a contributor from HLL.
- **Forgetting `GeocodingException`.** `ResolveLocationAction::run()` throws `GeocodingException` when geocoding fails. Always handle this.
- **Using `children` or `children_type` in API responses.** The correct keys are `locations` and `location_type`.
- **Filtering locations by `manual_verify`.** This deprecated column is no longer used. Don't scope queries with it.
125 changes: 125 additions & 0 deletions .ai/skills/metrics-pipeline/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
name: metrics-pipeline
description: MetricsService, RedisMetricsCollector, ProcessPhotoMetrics, metrics table, Redis stats, leaderboards, XP processing, and photo processing state (processed_at/fp/tags/xp).
---

# Metrics Pipeline

MetricsService is the **single writer** for all metrics — MySQL time-series and Redis aggregates. Nothing else touches metric counters. This is the golden rule.

## Key Files

- `app/Services/Metrics/MetricsService.php` — Single writer for MySQL + Redis
- `app/Services/Redis/RedisMetricsCollector.php` — Redis operations (stats, HLL, rankings, tags)
- `app/Services/Redis/RedisKeys.php` — All Redis key builders (single source of truth for naming)
- `app/Listeners/Metrics/ProcessPhotoMetrics.php` — Queued listener on `TagsVerifiedByAdmin`
- `app/Events/TagsVerifiedByAdmin.php` — Trigger event for metrics processing
- `app/Enums/LocationType.php` — Global(0), Country(1), State(2), City(3) with scope prefixes

## Invariants

1. **Single writer rule.** Only `MetricsService` writes to the `metrics` table and Redis metric keys. No other code may increment/decrement counters.
2. **Processing state is four columns:** `processed_at`, `processed_fp`, `processed_tags`, `processed_xp`. A photo with `processed_at = null` has never affected aggregates.
3. **Fingerprint-based idempotency.** MetricsService diffs old `processed_tags` JSON against new summary and writes only non-zero deltas. Safe to call repeatedly on any photo.
4. **Summary must exist before metrics fire.** `GeneratePhotoSummaryService::run()` MUST be called before `TagsVerifiedByAdmin` dispatches. MetricsService reads from `photo.summary`.
5. **Redis is a derived cache.** Rebuildable from the `metrics` table. `RedisKeys::*` is single source of truth for key naming.
6. **`processed_xp` must be INT UNSIGNED**, not TINYINT. Overflow bug documented in migration `2026_02_23_182605`.
7. **Tags count excludes categories** to avoid double-counting: `tags_count = objects + materials + brands + custom_tags`.

## Patterns

### How MetricsService processes a photo

```php
// MetricsService::processPhoto() — called by ProcessPhotoMetrics listener
DB::transaction(function () use ($photo) {
$photo = Photo::whereKey($photo->id)->lockForUpdate()->first();
$metrics = $this->extractMetricsFromPhoto($photo); // reads photo.summary
$fingerprint = $this->computeFingerprint($metrics['tags']);

// Skip if nothing changed (fingerprint + XP both match)
if ($photo->processed_fp === $fingerprint &&
(int)$photo->processed_xp === (int)$metrics['xp']) {
return;
}

// Route to create (first time) or update (re-tag)
if ($photo->processed_at !== null) {
$this->doUpdate($photo, $metrics, $fingerprint);
} else {
$this->doCreate($photo, $metrics, $fingerprint);
}
});
```

### MySQL upsert across timescales and locations

Each photo writes up to **20 rows**: 5 timescales (all-time, daily, weekly, monthly, yearly) x 4 location scopes (global, country, state, city).

```php
DB::table('metrics')->upsert($rows,
['timescale', 'location_type', 'location_id', 'user_id', 'year', 'month', 'week', 'bucket_date'],
[
'uploads' => DB::raw('GREATEST(uploads + VALUES(uploads), 0)'),
'tags' => DB::raw('GREATEST(tags + VALUES(tags), 0)'),
// ... same for brands, materials, custom_tags, litter, xp
]
);
```

Uploads delta: `+1` for create, `0` for update, `-1` for delete. `GREATEST(..., 0)` prevents negative counters.

### Redis operations happen after MySQL commit

```php
private function updateRedis(Photo $photo, array $payload, string $operation): void
{
DB::afterCommit(function () use ($photo, $payload, $operation) {
RedisMetricsCollector::processPhoto($photo, $payload, $operation);
});
}
```

### Redis key patterns (cluster-safe with hash tags)

```php
RedisKeys::global() // {g}
RedisKeys::country($id) // {c:$id}
RedisKeys::state($id) // {s:$id}
RedisKeys::city($id) // {ci:$id}
RedisKeys::user($userId) // {u:$userId}

RedisKeys::stats($scope) // $scope:stats (HASH: uploads, tags, litter, xp, ...)
RedisKeys::hll($scope) // $scope:hll (HyperLogLog for contributor count)
RedisKeys::objects($scope) // $scope:obj (HASH: object_id => count)
RedisKeys::ranking($scope, $dim) // $scope:rank:$dim (ZSET)
RedisKeys::userBitmap($userId) // {u:$userId}:bitmap (activity bitmap)
```

### Where TagsVerifiedByAdmin fires

1. **Trusted users tag a photo (web):** `AddTagsToPhotoAction::updateVerification()` — dispatches immediately after summary + XP.
2. **Teacher approves school photos:** `TeamPhotosController::approve()` — dispatches per photo after atomic `is_public = true` update.
3. **Trusted users tag a photo (mobile):** `ConvertV4TagsAction::run()` — dispatches after v4→v5 conversion + summary generation.

### Delete flow (metrics reversal)

```php
// MetricsService::deletePhoto() — called synchronously in controllers before soft-delete
// Reads processed_tags JSON, applies negative deltas, clears processed_* columns
$photo->update([
'processed_at' => null,
'processed_fp' => null,
'processed_tags' => null,
'processed_xp' => null,
]);
```

## Common Mistakes

- **Writing metrics outside MetricsService.** Never `DB::table('metrics')->increment(...)` or `Redis::hincrby(...)` directly.
- **Dispatching `TagsVerifiedByAdmin` before summary generation.** MetricsService reads `photo.summary` — null summary = zero metrics.
- **Comparing `processed_xp` as TINYINT.** Values above 127 overflow. Column must be UNSIGNED INT.
- **Forgetting row locking.** Always use `Photo::whereKey($id)->lockForUpdate()->first()` inside the transaction.
- **Assuming Redis is source of truth.** Redis is a cache. The `metrics` table is authoritative.
- **Including categories in `tags_count`.** Categories are groupings, not countable items. Only objects + materials + brands + custom_tags.
Loading