Repository navigation
Conversation
Deeds67
force-pushed
the
feat/no-location-filter
branch
from
August 23, 2026 17:42
0d3e2c7 to
d5f33cd
Compare
Deeds67
force-pushed
the
feat/no-location-filter
branch
from
September 2, 2026 20:35
6635ddd to
eb12d11
Compare
Deeds67
force-pushed
the
feat/no-location-filter
branch
from
September 11, 2026 19:19
eb12d11 to
efc3d04
Compare
Records the memories-view reconciliation cycle: the product-direction gate on immich-28675, the two zero-conflict Critical bugs found in review (upstream's Upcoming section provably always empty; pagination and deep links broken on the fork-only served path), the standing sidebarWeb/mobile-sourcing/e2e-generator divergences, and the deliberate user-visible regressions to call out in release notes. Remote CI on rebase/upstream-batch-161 is dispatched but not yet resolved.
…ories view Adopting upstream's memories page and viewer byte-identically (immich-28675) silently reverted three fork behaviours. None of them produced a conflict, a type error or a test failure. - The memories index is member 25 of the fork's 25-file branded-spinner swapped set, so it went back to `@immich/ui`'s generic LoadingSpinner instead of the Gallery-branded one. The upstream-preflight guard caught this; re-applied the swap in the same shape as the geolocation page. - The viewer stopped passing `enableGrouping` to GalleryViewer, so asset grouping (#625) defaulted off on the memory gallery strip. The prop is alive and still honoured on the component; re-added it and asserted it in the ported MemoryViewer spec, which now goes red if it is dropped again. - The card rendered the title only, so the rule engine's subtitles stopped reaching the UI: a recent-trip memory lost its "12 photos over 3 days", getMemorySubtitle became dead code and recent_trip_subtitle was orphaned in ten locales. Rendered it as a conditional second line, guarded by a new memories-page spec. Also formats MemoryViewer.spec.ts, which was failing //web:format.
…d page getAllMemories stopped paging as soon as a page came back shorter than the page size. That reads the wrong number: the server applies LIMIT/OFFSET in SQL (memory.repository.ts searchAccessible) and only afterwards drops memories whose type the viewer disabled and memories left with no viewable assets (memory.service.ts search). A page can therefore arrive short -- or empty -- with plenty of rows still behind it, so any user who turned a single memory type off got a short first page and a list that silently ended there. Key the stop on GET /memories/statistics instead. statisticsAccessible counts through the same accessibleSearchBuilder predicates without the LIMIT, so it is the one signal that reflects what the server actually paged over; web's memory-manager stops the same way. A missing or failing count degrades to an empty-page stop rather than back to the post-filter length, and never fails the call into the offline local-Drift fallback over a count the list can do without. The 50-page backstop is unchanged. The new "keeps paging past a page the server filtered short" case returns 3 memories against the old condition and 53 against this one.
…egressions Three corrections to this cycle's record, all found in the whole-branch review. D5 claimed rule-aware titles and subtitles were both preserved for free. Only titles were: upstream's page and viewer import memoryLaneTitle, and nothing on either surface imports getMemorySubtitle -- its one caller was the deleted memory-index-utils.ts. Reworded, with a dated note recording that the subtitle was restored on upstream's card rather than deleted, and why. The design doc now also records enableGrouping (#625) as a delta that must be re-applied on upstream's viewer. The report said the missing page offset made pagination "return page 1 forever, capping the list at roughly 250". Wrong: applyPreferences replaces the filter object wholesale and drops `size`, and the server applies LIMIT only when `size` is present, so the served path was unpaginated rather than capped. The fix that landed is still needed -- it is what lets a size-carrying caller page at all. /explore sends neither `for` nor `isUpcoming`, which is exactly the combination this cycle changed, so it now surfaces not-yet-shown memories in one flat strip with no Upcoming affordance. Documented in the user-visible behaviour-change list; deliberately not changed, since both workarounds re-introduce the caller/DTO ambiguity that change removed.
Reorder Tailwind classes to satisfy better-tailwindcss/enforce-consistent-class-order, and scope the item-card p query with :scope to satisfy unicorn/prefer-scoped-selector.
All 10 workflows green. Notes the two real defects CI caught that no local gate did (the branded-spinner swapped-set regression from adopting upstream's page byte-identically, and two lint violations in files eslint cannot check locally), and separates them from a GitHub infrastructure incident that reddened three intermediate runs while the status page read all-clear.
… block The batch-164 lockfile conflict (immich-30990, typescript-projects) was resolved by regenerating with injectWorkspacePackages temporarily disabled, so pnpm omitted the setting from the lockfile's settings block. Restore it so the block matches both upstream and the fork's previous lockfile byte for byte; workspace deps stay on `version: link:` (11 of them, zero `version: file:`).
…ract changes
Two zero-conflict semantic breaks from this batch:
- immich-30908 made OAuthRepository.getProfilePicture return the ArrayBuffer
directly instead of { data, contentType }. Upstream converted its own two
mocks; the fork's six S3 profile-picture mocks in auth.service.spec.ts still
built the old object and failed tsc. The two remaining contentType literals
are the fork's mockS3Backend.put assertions, which are unrelated and stay.
- immich-31016 replaced the RapidOCR wrapper with direct OCR processing,
deleting models.ocr.recognition.RapidTextRecognizer. The fork-only test
test_passes_model_root_dir_to_rapidocr patched that symbol, so it errored at
collection. recognition.py is byte-identical to upstream here, and the
property the test guarded — OCR models resolving from the local cache dir
rather than the network — is now structural in _load(), which builds an
OrtSession from self.model_path and reads self.model_dir / charset.txt.
The test is obsolete, so remove it rather than re-point it at a private.
…n nothing has scanned immich-31006 wraps the SDK's fetch in a `jsonOnly` guard that throws MalformedResponseError when a JSON-accepting request gets a non-204 response whose content-type is not JSON. `getLatestScan` returned `FaceRepairScanStatusDto | null`, and Nest emits a bare `null` as a 200 with an empty body and no content-type — so on a fresh instance every caller of getLatestScan() started throwing. The face-cleanup admin console caught it and rendered its load-error state instead of the first-run empty state, failing the fork's face-cleanup web e2e spec through all five retries. Upstream has no controller returning a nullable DTO, so nothing on their side exercises this and their own suites stayed green — the fork is the only consumer of the pattern. 204 is the honest representation of "no scan yet" and is exactly what the guard passes through untouched. Both statuses are now declared with @apiresponse, following asset-media.controller.ts's dual-status precedent, so the generated client models it as `{status:200; data: FaceRepairScanStatusDto} | {status:204}` rather than the previous untyped `data: object`. All four web call sites already treat a falsy result as "no scan", and no Dart/mobile client consumes this admin-only endpoint.
…ns-gallery
immich-30997/immich-30998 add a `server/src/schema/migrations/ORDER` manifest plus a
`Migration Order` workflow that verifies it on every PR. Two fork-side changes were needed.
The workflow's first step mints a token via immich-app's `create-workflow-token` action and
the `PUSH_O_MATIC_APP_*` secrets, which this repo does not have — every PR would have gone red
on it, and `make ci-invariants-check` forbids that pattern outright. Dropped the step; checkout
uses the default credentials and `use-mise` takes `${{ github.token }}`, matching every other
fork workflow.
Upstream's manifest only covers `migrations/`, so the fork's 61 `migrations-gallery/`
migrations were unguarded. Added an ORDER manifest for that folder too, with a
`migrations-gallery` mise task, `migrations:{sync,verify}-order:gallery` scripts, and a
checklist entry.
The gallery folder is checked for consistency only, NOT append-only. Gallery migrations use
hand-picked round timestamps and the server runs `allowUnorderedMigrations: true`
unconditionally (upstream allows it in dev only), so a branch landing with a lower timestamp
than one merged meanwhile is safe here. Consistency alone still makes two branches that both
add a gallery migration conflict in git on ORDER's tail, which is the reason upstream added it.
Both checks were proven to fail as well as pass: a doctored append-only baseline and a
gallery ORDER with a line removed each exit 1.
… baseline Two follow-ups to the adapted immich-30998 workflow. The rolling rebase branch sits off `main` for long stretches, and the workflow only triggers on `pull_request` / `push: main`, so it would never run there. Added `workflow_dispatch` — it becomes usable once the file reaches the default branch. `--append-only-from` reads the base commit's ORDER file unconditionally, which throws whenever the base predates the manifest. That is not hypothetical: at the next `main` cutover `github.event.before` is the pre-rebase main, which has no ORDER file, so the push run would have failed. The step now falls back to the plain consistency check and emits a notice, and resumes enforcing append-only as soon as the base carries an ORDER file. Simulated all three states: baseline present + valid exits 0, baseline absent exits 0 via the consistency path, baseline present + violated still exits 1.
A three-wave multi-agent review of this branch turned up 58 defects. This records the decisions taken and the traps worth keeping, not the full findings list — a standing wishlist rots. The durable half is Part 1: five CI gates that look green and prove nothing, and three detectors that earned their keep (git ls-tree set algebra for fork deletions upstream later resurrects; zero-producer test-id greps; and treating a gate that does not cover what it appears to as a finding in itself). Part 2 carries one entry per decided fix with its mechanism, sites and the assertion that would prove it, so the work can be done from this file.
Each of these looks green and proves nothing. Together they are why several defects survived a 10/10 CI run. - Schema drift: `pnpm --filter migrations:generate` selects PACKAGES, not scripts, so it matched nothing, exited non-zero, and continue-on-error ate it. This is the only detector for a table declaration drifting from what the migrations produce — during the option-M landing it was the sole thing that caught the face-review tables declaring personId with FKs to a dropped column while tsc sat at zero. Name the package, and let failure go red. - Migration ORDER: `github.event.before` exists only on push, so on workflow_dispatch — the only mode a branch off main can use — the base checkout fell back to the triggering ref and --append-only-from compared ORDER against itself. Skip the checkout instead and let the existing missing-baseline path degrade to the consistency check, with its notice. - upstream-preflight: the path filter watched three of the nine paths its specs read, so a PR reverting the branded-spinner swap or dropping the migration compatibility alias ran with the job skipped — and a skipped job is indistinguishable from a passing one. AGENTS.md is listed alongside CLAUDE.md because the latter is a symlink. - ci-invariants-check: run by no workflow at all; it fired only when someone typed it. Wired in. It also read zero files for a declared path that no longer resolves and reported "passed", so an upstream relocation turned the gate green — now that invariant fails and names the path, while the others still report. - dart-nullable-array-items: invoked via a package script carrying --passWithNoTests, so renaming the spec would exit 0. Call vitest directly. Also completes the branded-spinner guard, which listed 25 of 27 call sites. The list is hand-maintained and drifts as the swap set grows, so the fix is a third test deriving the truth from the tree rather than two more paths. Both new failure modes were proved red before being trusted.
Both are Shape I in its hardest form: the fork's rule is a DELETION, so upstream re-creating the path produces a delete/modify conflict that resolves toward upstream leaving no textual trace and no gate to notice. `packages/scripts` was dropped in bc06e84 as upstream release-version tooling the fork does not use. A Renovate commit touched its package.json this cycle and the replay brought the file back alone — an orphan workspace member declaring build/check/lint/test scripts over no source, plus six phantom dependencies in the lockfile. Nothing in CI built it, and --frozen-lockfile passed because the lockfile had been regenerated with the importer. The forward risk was the real one: with the path present, the next upstream batch adding files under it would apply cleanly rather than conflict. `e2e/docker-compose.yml` lost its cache_from removal (#171) the same way. Both tags 404, so today it costs two failed registry probes per build, but if one ever resolves the fork's e2e image imports upstream-built layers. Adds fork-deletions.spec.ts so the next occurrence fails a test instead of landing silently, and extends the upstream-preflight path filter to packages/** so that test actually runs on a PR that resurrects one. Lockfile regenerated with injectWorkspacePackages left at true: 11 `link:` entries byte-identical to before, 0 `file:packages/`, frozen install clean.
The paragraph named 3.44.9 while both real pins said 3.47.1 — upstream moved the pin at the new base, and this line was independently edited to a different wrong value in the same cycle. It also quoted a mise.toml syntax that no longer exists. It already warned "read the pin rather than trusting this line; it has gone stale before" and then restated the number anyway, which is the trap. Name where the pin lives instead of what it currently says.
immich-28675 moved the memory viewer from /memory?id= to /memories/:id. The fork adopted the move, but the deep-link table in open-in-app.ts is fork-only, so upstream never touched it and the rename produced no conflict: every real memory URL stopped matching, pathToDeepLink returned null, and the banner silently never rendered on mobile web. Its spec passed 39/39 throughout because it pinned the retired paths, and the e2e spec only ever navigates to /photos/*. Adds the live routes with coverage that goes red without them, and keeps the old ones — the 307 shim still serves shared and bookmarked links. Also records why mobile/analysis_options.yaml carries no convergence marker: one would break the byte-identity that taking upstream's file buys. Note the mobile half is still incomplete: deep_link.service.dart handles only path == "/memory" and discards ?id=, so a memory-id deep link needs a mobile change before the emitted id does anything.
The location section renders 'No locations found' when there are no countries -- which is exactly a library with nothing geotagged, the case this feature serves. Guarding only on a selected presence hides the rows on first open.
…ionsResponseDto construction sites The Task 4 Dart client regen marked both fields required, which had left dart analyze red across every call site that builds this DTO by hand (the lib fallback-response construction plus test fixtures/mocks in files outside Tasks 10/11's scope). Mechanical fix: add both arguments everywhere. Test fixtures use false (empty fixtures); the lib fallback site also uses false since it only fires when the live API response is null — the live path still returns the server's real values unchanged.
Placed at e2e/src/specs/web/ (the real-backend project) rather than the brief's e2e/src/ui/specs/timeline/ path, which belongs to the mocked-network "ui" project and has no real database to seed GPS EXIF against.
city/state/country/locationPresence are one location group, but four request-building sites only ever forwarded the first three: filter-panel.svelte's unified re-fetch effect, the photos and space detail pages' suggestion loaders, and album-filter-config.ts. With "No location" selected, the People/Tags/Camera-make suggestion lists kept describing the unfiltered set instead of narrowing, so mobile and web diverged. map-filter-config.ts is untouched — it deliberately suppresses this filter. Adds a panel-level test proving the unified suggestionsProvider receives locationPresence when a no-location entry is selected.
No test anywhere exercised the DTO -> service -> repository wire path for locationPresence; the medium tests call the repository directly. Adds a sibling to the existing city+make case: every fixture asset in this describe block gets coordinates in the outer beforeAll, so a correctly wired noGps filter returns zero buckets against a non-zero unfiltered baseline -- a silently dropped param would instead return the same non-zero count as unfiltered. Not executed: the only live e2e stack was built from a different branch and doesn't contain these server changes.
getFilterSuggestions grew a 7th parallel query (location presence) during this branch, and the Countries extraction now also excludes state alongside country/city. Updates the "What updates" table, the server flow's query count and list, and the shared-helper count to match.
The row-less-asset paragraph named a freshly uploaded, not-yet- extracted asset as the case that inflates a noGps bucket count. That case barely exists: asset-media.service.ts:389 upserts an asset_exif row (with fileSizeInByte) during upload, before extraction runs. The real case is external library imports: library.service.ts:275 -> AssetRepository.createAll inserts into asset only, with no asset_exif row until the extraction job processes it -- and during a large Connected Libraries scan that gap is hours, not seconds. The same gap is permanent for any asset whose extraction job failed. Drops the "transient in practice, seconds" framing; the deferred-fix decision is unchanged.
The Known-limitation paragraph was corrected to name external library imports as the source of row-less assets, but the data-model section still claimed uploads leave no exif row. Uploads write one at asset-media.service.ts:389, before extraction.
getSmartSearchFacets and getFilterSuggestions both gained hasNoGpsAssets/hasNoPlaceNameAssets, and these two tests pin the whole response object with toEqual, so any new field breaks them regardless of correctness.
…ng empty The noGps bucket assertion assumed an empty result from this describe's all-geotagged fixture, but other describes in the same file create admin-owned assets with no coordinates against the same database, so noGps legitimately matches those. Comparing filtered against unfiltered keeps the test falsifiable without depending on global DB state.
The local helper tripped unicorn/consistent-function-scoping; the neighbouring assertions in this file already reduce inline.
Two space filter-panel tests asserted that a space whose assets carry no EXIF shows the 'no locations found' message. Those assets are exactly what the new filter finds, so the section now offers the 'No location' row instead — reporting the section empty beside a working filter for it would contradict itself. Camera keeps its empty message.
Restores the regeneration that the rebase onto #910 collapsed: the generated dump now carries the two location-presence facet queries alongside the favourites/album-membership ones main added. The Dart models are re-emitted from the same spec so the hand-merged blank line matches the generator.
… entries exist #910 landed a section-availability gate that hides a filter section whose facet cannot filter anything, and it judges the location section on `countries` alone. That predates #868: a library where nothing has coordinates now has something to offer there — the "No location" entry itself — so the gate was hiding the very feature that scope exists for, on both web and mobile. - web `isSectionEmpty` and mobile `_facetEmpty` now also consult hasNoGpsAssets / hasNoPlaceNameAssets before calling the section empty. - mobile `hasActiveFilterFor(places)` counts an active locationPresence, so selecting an entry can never strand it behind a hidden section. - mobile `_isEmptyForFacets` counts locationPresence too — the facets request forwards it, so a filter carrying only that field must still take a distinct baseline key rather than reusing the current one. - the two album pages #993 added seed the new facet fields in their local emptyFilterSuggestions().
#910's "hides the sections that cannot filter anything" seeds one asset with no EXIF and asserts all eight gated sections vanish. Since #868 that is no longer true of location: an asset with no coordinates is exactly what the "No location" entry filters for, so the section can filter something and stays. Drops location from the hidden loop and asserts the positive instead — the section renders and offers the noGps row rather than its empty placeholder. The sibling spaces-filter-panel assertion already passed this run, so this is the last place the old expectation survived.
#1021 moved durable design docs to top-level specs/ and stopped committing execution plans at all — they are session checklists that expire when the work lands, and under docs/ they were prettier-gated and tripped Docs Build. The rebase carried the design doc across to specs/ on its own; the plan is deleted rather than resurrecting docs/superpowers/plans/. Claude-Session: https://claude.ai/code/session_01XKxtXPAwtQgFHgmRux2rcF
location-country-nesting.spec.ts landed on main while this branch was open, and its suggestions() helper builds a FilterSuggestionsResponse literal — which now requires hasNoGpsAssets / hasNoPlaceNameAssets. Both default to false, so the absence-of-location rows stay out of the list and the nesting assertions still see nothing between the search box and the country rows. Claude-Session: https://claude.ai/code/session_01XKxtXPAwtQgFHgmRux2rcF
… widgets The v3.2 cutover roughly doubled mobile/analysis_options.yaml's rule set and "Run Dart Code Analysis" runs `dart analyze --fatal-infos`, so an info is a failed build. Two lines this branch adds would trip it: - `always_put_control_body_on_new_line` (raised to a warning in analyzer.errors) on the presence-rows early return in places_picker.page.dart. - `unnecessary_parenthesis` on the parenthesised ternary condition in deep_section_scaffold.widget.dart. Inside that `cache != null` branch the predicate is exactly the `isEmpty` local computed three lines above, so this reuses it instead of restating it — shorter and provably identical. Both are behaviour-preserving. Kept as one commit on top rather than folded into the feature commits, so the replayed history stays faithful to what was reviewed.
Full regen pass after the replay, run from this worktree (never via a `mise //:*` task, whose `//:` prefix resolves to the MAIN checkout and would have rewritten the wrong tree): pnpm -C packages/sdk build && pnpm -C packages/plugin-sdk build && pnpm -C server build node server/dist/bin/sync-open-api.js oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas ... node server/dist/bin/sync-sql.js (against a throwaway DB, not the shared e2e one) Only fetch-client.ts moved. The OpenAPI spec and all 63 generated .sql files — search.repository.sql included, with both location-presence facet queries — came back byte-identical to what the replay produced, so the mechanical conflict resolutions in those generated files were already correct. The one delta is placement, not content: resolving the fetch-client conflict by hand put `LocationPresence` after `SearchOrderField`, while the generator emits it before. Same two members either way; the generator wins. mobile/openapi/ is deliberately absent — the Dart client is generated at build time into the gitignored mobile/generated/openapi/ since the v3.2.0 cutover.
…lints SearchFilter is a freezed class now, so the cascade assignment `SearchFilter.empty()..location = ...` no longer compiles — the file failed to load and took the whole time-buckets suite with it. It uses copyWith, matching the idiom every neighbouring test already follows. The rest was newly-enforced lints: the generated locationPresence Optional holds a nullable, so both null assertions on it were redundant, and the haptic call in the two places surfaces needed the `unawaited` its siblings in the same file already had. Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
…Presence guard main re-described `timeBucket` as YYYY-MM-DDT00:00:00.000Z (#1093) while this branch wrapped the same schemas in the `locationPresence` exclusivity pipe. Keep both: the guard now sits on a base schema that carries main's format.
Deeds67
force-pushed
the
feat/no-location-filter
branch
from
September 27, 2026 19:58
7b8a592 to
a79c567
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the request in #868: a way to find photos that carry no location.
The location filter can currently only narrow to a place — it lists countries, expands each into cities, and displays an incoming state it cannot browse. There is no way to express the absence of a place, so the photos that most need finding are the only ones the filter cannot reach.
What this adds
Two mutually exclusive entries in the location filter section:
They are deliberately disjoint. Their union is the complement of a fully-located asset (
neither matches ⟺ latitude IS NOT NULL AND city IS NOT NULL), which a medium test asserts directly.Design notes
noGpsis a correlatedNOT EXISTS, not a join predicate.asset_exifrows are not guaranteed — an external library import inserts intoassetonly, with no exif row until the extraction job reaches it (hours during a large scan, permanently if extraction failed). An inner join would drop exactly the assets that most obviously have no GPS. This mirrors the existingtagIds === null("assets with no tags") predicate.locationPresencejoins the location group rather than adding a dimension.city,state,countryandlocationPresenceremain one filter with one chip: selecting any one replaces the whole group, the active-filter count is unchanged, and the DTOs reject the combination rather than silently resolving it. It also joins the suggestion self-exclusion lists, so selecting one entry cannot empty the country list or hide its sibling.Both query builders. The predicates land in
searchAssetBuilderLegacyandwithTimeBucketAssetFilters, which a comment indatabase.tsrequires to agree; a test asserts both return the same ids.The map suppresses both entries. Map markers join on
asset_exif.latitude IS NOT NULL, so "No location" could only ever produce an empty map. The map forces both gating flags off and drops the value at URL-decode time, so a link copied from/photoscannot leave an invisible filter applied there.No new index. Neither
asset_exif.latitudenorcityhas a btree index, and the predicates ride the primary key after the query's owner and visibility gates have narrowed the set. A partial index on a low-selectivity predicate is unlikely to pay for itself; revisit withEXPLAIN ANALYZEon a large library if it proves slow.Scope
Server predicates and DTOs, two suggestion flags gating whether each entry is offered, regenerated TypeScript and Dart clients, strings in ten locales, the web filter panel (state, URL codec, rows, per-surface forwarding, map suppression), and all three mobile places surfaces.
Not included: the
/searchroute's typed-search tokens, surfacing unlocated assets on the map, and the dormant V3 branch filter.Testing
Medium tests cover both predicates, their disjointness, the partition identity, the no-exif-row case, cross-user isolation, trash and archive gating, and shared-space access. Unit tests cover the DTO exclusivity across every controller-facing schema, the web filter state, URL round-trip and removal, the panel rows, and the mobile model, chip, providers and three surfaces.
One gap: the Playwright spec (
e2e/src/specs/web/no-location-filter.e2e-spec.ts) and the API-level test forGET /timeline/buckets?locationPresence=noGpshave not been executed — no compatible stack was available locally. Every selector, helper and fixture was verified against source, but they need a real run before being trusted.Known limitation
getTimeBucket's projection CTE joinsasset_exifunconditionally, so an asset with no exif row is counted bygetTimeBucketsbut dropped from the rendered rows. "No location" is the filter designed to surface exactly those assets, so its bucket count can exceed the photos drawn during a library scan.This is pre-existing and not introduced here — the join is unconditional, so the same disagreement reproduces for any query that lets a row-less asset through, including an unfiltered timeline. The fix is a left join, which alters every timeline query in the application and would start surfacing unprocessed assets in the main timeline; that is a product decision rather than a bug fix, and is deliberately out of scope. Documented in the design spec with a follow-up worth filing.
Follow-ups
i18n/ru.json—Без места/Место без названияshare the root noun место.местоположениеwould separate them but conflicts with the location filter's sibling strings. Wants a native speaker._SearchMoreRowreads "Search 0 places →" in the zero-countries state; correcting it needs new strings in ten locales._PresenceEntryis defined once per mobile surface file; a shared type would remove the duplication.