Skip to content

Release: develop → main (v2.16.0) - #755

Queued
hokiepokedad2 wants to merge 36 commits into
mainfrom
develop
Queued

Release: develop → main (v2.16.0)#755
hokiepokedad2 wants to merge 36 commits into
mainfrom
develop

Conversation

@hokiepokedad2

@hokiepokedad2 hokiepokedad2 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

35 commits since v2.15.3. Minor bump: this carries new user-facing filters and a new page, not only fixes.

New

  • Per-alarm delivery scope. Each alert can reach you anywhere in your areas, within a radius of your pin or a saved place, or only in specific areas — instead of every alarm inheriting one profile-wide setting. One shared control asks that question everywhere it's asked, in all twenty dialogs and from the card itself.
  • Saved places. Name the points your alerts measure from. Areas and Places are one page now; /places redirects there.
  • Minimum time left on Pokemon rules — skip spawns that will be gone before you can reach them.
  • PVP mega evolution targeting — Base, Mega, Mega X, Mega Y, which PoracleNG 5.1.0 ranks separately.
  • Quest stardust rewards and minimum amounts — the reward type the site could display but never create, plus "at least three of them" for items, candy and mega energy.
  • Fort description changes — PoracleNG's sixth change type, previously unreachable.
  • Alert defaults gain a default place; alert language moved beside display language in the user menu.
  • A Versions card on Admin → Settings showing what this site and its Poracle server are running, whether either is behind, and a red warning when Poracle is older than 5.1.0 — which is what this release needs.

Fixed

21 entries. Eight were defects in this release's own new code, found by using it rather than reading it: a mega picker that stored nothing, the same picker unreachable when editing, quest minimums that were create-only, a server card shipped on an unrouted page, two pages overflowing a phone screen, and hint text overlapping headings twice.

Documentation

The README, 31 docs pages and the in-app guide were audited against the code — 101 findings, 35 of them sentences that would mislead a reader today. The in-app guide is corrected in all eleven languages and eleven screenshots were retaken. Publishing this release is also what publishes those docs, since docs.yml deploys from main.

Compatibility

Requires PoracleNG 5.1.0 or newer, which production already runs. Below that the delivery scope, the mega filter and the time-left filter write columns that don't exist — silently. This release adds the check that says so, in the logs at startup and on the admin card.

Cross-checked against PoracleNG main (c5e08cb4): every column of all ten tracking types is either written or has a recorded reason, and every filter the Discord commands accept maps to something the web can set.

Before publishing

Merging this ships nothing — docker-publish.yml builds :latest on release: published, not on a push to main. Publishing the release is what reaches production, within watchtower's 60-second poll.

https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ

…731)

Every alarm write built its body by serializing the typed model, so any
column PoracleNG has that PoracleWeb never modelled was absent from the
request. The write carries a uid, so PoracleNG upserts the row and stores
the column default over the user's value.

PoracleNG 5.1.0, which prod now runs, added override_location_label,
override_areas and pvp_ranking_evolution to the tracking API. Setting any
of them with the bot and then pressing Update Distance, toggling
auto-delete or editing the alarm on the web erased them.

The bulk paths now rewrite the stored rows in place and set only the field
being changed. The edit paths merge the stored row into the body before it
is sent. Neither enumerates the new fields, so costume (PoracleNG 5.2.0)
is covered by the same change.

Merging runs before the collision guards on purpose: CountUpdatableDifferences
only compares properties present in the submission, so an unmodelled field
could not tell two alarms apart and the guard refused edits PoracleNG would
have accepted.

Also re-read the diff:"update" tags at 5.1.0 as TrackingUpdateReconciler's
comment instructs. Every entry still matches; recorded in the doc comment
along with why maxbattle's absence from the table is harmless.
* fix: preserve alarm fields PoracleWeb does not model on write (#730)

Every alarm write built its body by serializing the typed model, so any
column PoracleNG has that PoracleWeb never modelled was absent from the
request. The write carries a uid, so PoracleNG upserts the row and stores
the column default over the user's value.

PoracleNG 5.1.0, which prod now runs, added override_location_label,
override_areas and pvp_ranking_evolution to the tracking API. Setting any
of them with the bot and then pressing Update Distance, toggling
auto-delete or editing the alarm on the web erased them.

The bulk paths now rewrite the stored rows in place and set only the field
being changed. The edit paths merge the stored row into the body before it
is sent. Neither enumerates the new fields, so costume (PoracleNG 5.2.0)
is covered by the same change.

Merging runs before the collision guards on purpose: CountUpdatableDifferences
only compares properties present in the submission, so an unmodelled field
could not tell two alarms apart and the guard refused edits PoracleNG would
have accepted.

Also re-read the diff:"update" tags at 5.1.0 as TrackingUpdateReconciler's
comment instructs. Every entry still matches; recorded in the doc comment
along with why maxbattle's absence from the table is harmless.

* feat: per-alarm delivery scope, including user-drawn geofences (#730)

PoracleNG 5.1.0 gives every alarm its own scope: a saved place to measure a
radius from, or a set of areas to be confined to, instead of inheriting the
profile's pin and area list. This carries it through PoracleWeb.

- override_location_label and override_areas on all ten alarm models, their
  Create and Update DTOs, and the mapping extensions.
- Saved places over PoracleNG's /locations API: list, add, delete. Delete
  surfaces the 409 referencing_rules list instead of flattening it, because
  the useful answer is which alarms to repoint first.
- PoracleNG's three mutual-exclusion rules are mirrored and refused before
  the write, at the proxy seam so the callers that never touch a controller
  are covered too (#548, #565).

The interesting part is user-drawn geofences. PoracleNG validates
override_areas against GetAvailableAreas, which filters on userSelectable;
PoracleWeb serves user geofences with userSelectable=false on purpose, so
naming one fails the whole request with 400 "area not permitted" -- it
rejects rather than stripping, unlike setAreas.

Matching never consults that flag: resolveOverride hands the rule's areas to
areaOverlap, a name comparison against the fences the spawn fell in. So the
permitted names go to PoracleNG, the full list is written straight into the
column, and state is reloaded. Verified against 5.1.0's matcher.

That lives in UserOwnedOverrideAreaProxy, a decorator over the tracking
proxy, rather than in the ten alarm services: the outbound body already
carries everything the decision needs, and the service-layer version would
have meant ten new constructor parameters and the same logic ten times.

Also tightens PreserveUnmodelled from #730: now that the models declare
these fields, every serialized alarm carries them as explicit nulls whether
or not the caller read them. Null now means "not stated, keep what is
stored"; an empty list or empty string is how a scope is cleared -- the same
split the models already use for gym_id. Without this, modelling the fields
would have reintroduced the erasure #730 exists to prevent.
* feat(where): the shared control for an alarm's delivery scope

First half of the per-alarm scope UI. The chip and the sheet exist and are
covered; wiring them into the nine alarm dialogs, the card templates and a
Places screen is the follow-up.

Every alarm has always answered "where should this reach me". Before
PoracleNG 5.1.0 the answer was inherited and invisible: one profile pin, one
area list, shared by everything. This makes the answer visible and editable
per alarm, so the work is stating something that was already there rather
than bolting on an override system -- which is also why nothing in the UI
says "override".

- alarm-scope.ts reads the two override columns and the radius back into one
  discriminated value, and writes it out again. That is what lets the sheet
  offer a radio group instead of three fields and three error messages: the
  states PoracleNG refuses cannot be expressed.
- Clearing sends empty values, never null. Null means "not stated, keep what
  is stored" on the write path, so a null here would make an override
  impossible to take off.
- WhereChipComponent renders the scope as a sentence fragment and is the way
  into the sheet, so the same control reads and edits the same idea.
- WhereSheetComponent offers the three modes. Its area picker lists the
  user's own geofences alongside admin areas, which is only honest because
  the backend now writes those past PoracleNG's user-selectable filter.
- PlacesService holds the saved places as a signal, since the sheet, the
  Places screen and the cards all read the same list.

Locale keys added to all eleven files, English text throughout pending
translation.

* feat(where): Places, and a pick-only mode for the location map

Second slice of the per-alarm scope UI. "Near a place" is unusable without
somewhere to make places, so this is the half that has to land with it.

- PlacesDialogComponent, in the user menu beside Alert Defaults: the profile
  pin, the named places, add and delete.
- LocationDialogComponent gains `pickOnly`. It saved the chosen point as the
  profile pin unconditionally, which is what it was for -- so borrowing it to
  name a place would have quietly relocated every alarm without an override.
  Three existing callers are untouched.
- Naming reuses ConfirmDialog's promptField, which already does
  name-with-duplicate-check, rather than adding a third dialog that asks for
  one string. The existing labels are passed in so it can refuse a duplicate
  before the request goes out.
- Delete surfaces the 409 referencing_rules count. PoracleNG refuses to
  orphan a label, and the useful thing to say is how many alerts are in the
  way, not that it failed.

Seven component tests, including both delete failure paths and the cancelled
naming step. The pick-only assertion is there because the failure it guards
against is silent: without it the pin moves and nothing says so.

* feat(where): chip on the Pokemon card, and fix the pin-radius reading

Wiring the first card found a bug in the model I shipped yesterday.

scopeOf collapsed "within N km of my pin" into the inherited reading, so an
alarm with a radius and no place rendered as "Anywhere in my areas" -- the
opposite of what it does, in the one place the card is supposed to be
authoritative. A radius with no place is the behaviour that predates
per-alarm scope and it needs its own reading, so the profile mode now carries
a distance and describeScope words it separately.

That also corrected the sheet. Offering "areas / near a place / specific
areas" left no way to express the most common alarm there is. The second
option is now "near a point" with a target select whose first entry is the
pin, because to a person that is one choice with a target rather than two
unrelated modes. Three options still, and they still map onto the three
mutual-exclusion rules PoracleNG enforces.

- WhereChipComponent replaces the areas-or-distance badge on the Pokemon
  card. Clicking it opens the sheet, so scope can be changed without opening
  the whole edit dialog.
- The list loads the profile's selected areas, only so the inherited wording
  can be honest: "anywhere in my areas" is a lie for someone with none.
- 11 new tests across the chip and the scope model, including both readings
  of a bare radius, which is the case that was wrong.

* feat(where): scope in the Pokemon add and edit dialogs

The add dialog's radius gains a "measured from" selector: the pin, as
before, or a saved place. Everything funnels through scopeToFields, so the
card chip, the scope sheet and this dialog produce the same wire format from
one implementation.

The edit dialog deliberately does NOT gain the control. Scope is changed from
the card chip, which is one control in one place; a second editor would be
two paths that can disagree. What the dialog needed was to stop being able to
destroy a scope it cannot describe:

- An area-confined alarm has no radius, so the areas-or-distance control
  cannot express it. The control is hidden for those and the stored distance
  is sent back unchanged, rather than offering a change that would silently
  discard the areas.
- The dialog shows the scope read-only, so an alarm aimed at a place does not
  look like a plain radius while being edited.

Three add-dialog tests, including the one that matters most: a plain radius
must NOT acquire a location override just because the field now exists.

Full frontend suite: 1025 passing.

* fix(where): import DecimalPipe in the Places dialog

The production build resolves template pipes; jest and tsc do not. `| number`
on the coordinate rows compiled locally and failed CI with NG8004.

Verification for Angular template changes needs `npm run build`, not just the
type check and the unit tests.
Cards
- Raids, eggs, quests and max battles share app-alarm-info, so that component
  now renders the Where chip instead of its own distance display. One change,
  four types, and no chance of the four drifting apart.
- Gyms, lures, nests, invasions and fort changes each carried their own
  distance-chip markup; all five now use the chip.
- Every list loads the profile's selected areas, used only to keep the
  inherited wording honest.
- Clicking a chip opens the scope sheet, on all ten types. Raids and eggs
  share a list, so the tracking type is a parameter rather than two methods.

Edit dialogs
- The areas-or-distance control cannot express an area-confined alarm, and
  switching it to a distance would have sent both and been refused by
  PoracleNG. It is hidden for those alarms, and every dialog shows the scope
  read-only above it.

Translations
- All 37 WHERE strings plus the Places menu entry into de, es, fr, it, nl,
  pl, pt, pt-BR, sv and da. The applier asserts each locale carries exactly
  the English key set and that every {{placeholder}} survives, since a
  dropped one renders as literal braces to the user. RADIUS_KM matching
  English in Danish and German, and PLACE_NAME in German, are cognates.

Verified with npm run build, not just tsc and jest -- template errors only
surface in the production build, which is how NG8004 reached CI last time.
Two remaining pieces of per-alarm scope.

Default place
- AlertDefaultsService remembers a place beside the mode and radius, and the
  nine add dialogs that lacked it gain the "measured from" selector Pokemon
  already had. All ten seed from the saved default.
- save() drops the place when the mode is areas. A place only means something
  alongside a radius, and the two are mutually exclusive upstream, so keeping
  one would seed every new alarm with a scope PoracleNG refuses.
- reconcilePlace() forgets a place that no longer exists, so deleting one
  cannot keep stamping a rejected label onto new alarms.

Mega PVP
- pvp_ranking_evolution on the Monster model, a Base / Mega / Mega X / Mega Y
  control in the add and edit dialogs, and a suffix on the card's PVP badge.
- Only sent when the rule has a league. Carrying it on a non-PVP alarm would
  be a filter nobody asked for on a field PoracleNG still reads.
- It works whatever include_mega_evolution is set to: that flag only decides
  the default for rules that do not state a mode. Confirmed against 5.1.0's
  matcher, where filterMega is gone and mega entries are split per evolution.

Translations for all new strings in the ten non-English locales.

Two rounds of build-only failures again, both invisible to tsc and jest:
mat-option unknown in three dialogs whose imports lacked MatSelectModule, and
a spec stub for AlertDefaultsService missing the new accessor.
* feat(where): default place for new alarms, and mega PVP

Two remaining pieces of per-alarm scope.

Default place
- AlertDefaultsService remembers a place beside the mode and radius, and the
  nine add dialogs that lacked it gain the "measured from" selector Pokemon
  already had. All ten seed from the saved default.
- save() drops the place when the mode is areas. A place only means something
  alongside a radius, and the two are mutually exclusive upstream, so keeping
  one would seed every new alarm with a scope PoracleNG refuses.
- reconcilePlace() forgets a place that no longer exists, so deleting one
  cannot keep stamping a rejected label onto new alarms.

Mega PVP
- pvp_ranking_evolution on the Monster model, a Base / Mega / Mega X / Mega Y
  control in the add and edit dialogs, and a suffix on the card's PVP badge.
- Only sent when the rule has a league. Carrying it on a non-PVP alarm would
  be a filter nobody asked for on a field PoracleNG still reads.
- It works whatever include_mega_evolution is set to: that flag only decides
  the default for rules that do not state a mode. Confirmed against 5.1.0's
  matcher, where filterMega is gone and mega entries are split per evolution.

Translations for all new strings in the ten non-English locales.

Two rounds of build-only failures again, both invisible to tsc and jest:
mat-option unknown in three dialogs whose imports lacked MatSelectModule, and
a spec stub for AlertDefaultsService missing the new accessor.

* fix(where): Places belongs in the nav, not the user menu

Places was a dialog next to Alert Defaults. Wrong on two counts.

It is data, not a preference. Alert Defaults is localStorage; Places is
server-side CRUD, exactly like My Geofences, which has its own nav item.

And the Areas page already answers this question: it opens with two method
cards, Areas and Location, where the Location card holds the pin and its
Set/Change button. Named points are just more pins, so hiding them in a
settings menu split one idea across two surfaces.

- The dialog becomes a page at /places, in the settings nav group beside
  Areas and My Geofences. The user-menu entry and its string are gone.
- The route carries disabledFeatureGuard('disable_location'), matching
  LocationController, which already gates the endpoints it calls. A nav item
  alone would have left the page reachable by URL.
- The Areas location card links to it, so the two surfaces reference each
  other rather than one being hidden.

Also caught while doing it: NAV.PLACES was missing from all eleven locale
files, English included, so the sidebar would have rendered the literal key.
My earlier parity check could not see it, because it compares locales
against English and English was missing it too. Added a check that reads the
keys templates actually ask for and asserts each exists in en.json. It found
two more, COMMON.ERROR and COMMON.SAVED, both pre-existing in the invasion,
max-battle and quest add dialogs and left alone here.

* docs: fold the superseded Places entry into the accurate one

Both described the same unreleased feature and disagreed about where it
lives. Nothing has shipped, so the log should say where Places is rather
than record the route it took.
…737)

Style
The page used a mat-list; every comparable page in the app uses a card grid
with an accent bar, skeleton loaders and a dashed empty state. It now matches
Geofences, which is the closest sibling. The pin appears as the first card
rather than a banner above the list, because it IS a place — the unnamed one
every alert falls back to — and a separate treatment said otherwise. Its
accent and icon are muted, since it is the fallback and not a choice.

Dead end
The scope picker's empty state read "Add one from the location menu", naming
a menu removed when Places became a page. Wrong in all eleven locales.

Rather than just correct the words, the select now ends with Add a place: it
opens the map picker, asks for a name, saves and selects it. Creating a place
is only ever wanted at that exact moment, and sending someone to another
screen lost the alarm they were mid-edit on — thirteen steps across two
contexts, down to eight in one.

Also added a check that reads the keys templates ask for and asserts each
exists in en.json, then that every locale carries them. Parity against
English cannot catch a key English is missing too, which is how NAV.PLACES
went absent from all eleven. It reports two pre-existing gaps, COMMON.ERROR
and COMMON.SAVED, left alone here.
Three design reviews, run independently, all reached the same objection to
the nav item I added yesterday: Places is a thin, set-and-forget list that
bought permanent nav space, and the pin and the named places were still on
two different pages. The cross-link I added from the Areas page was the tell
— it existed because the content was in the wrong place.

- The Places page becomes a section of the Areas page, directly under the
  Location card that holds the pin. It no longer renders a pin card of its
  own; the card above it is the pin.
- One nav item, relabelled Areas & Places, replacing two. The page title
  matches. /places redirects rather than 404s.
- The notification language block moved from the middle of the page to the
  end. It has nothing to do with areas and was interrupting the picker.

Two of the three reviews argued AGAINST merging Areas and My Geofences, and
the third named that as the strongest counter to its own proposal, so
Geofences stays its own item. It hosts drawing, submit-for-review and
approval states: a workspace, not a list.

Left undone deliberately: Areas and My Geofences both edit profiles.area
through different controls, chips on one page and slide toggles on the other.
Verified rather than taken on trust. That is a deeper incoherence than
anything this change touches and wants its own decision.
)

Cleared pin read back as 0,0
Poracle stores "no pin" as 0,0, not null. Clearing set the page's own state
to null and looked right; the next visit read 0,0 at face value and rendered
it as coordinates. The literal was open-coded in six components, each
rewriting it, so the rule now lives in one helper and the pin reads through
it in both the Areas page and PlacesService.

Map opened too far out
The initial-view ladder ranked the selection above the pin. A multi-area
selection frames a whole region, so the map opened where nothing could be
clicked. The pin now outranks it.

It does NOT outrank the user's own shapes. Three existing tests failed on the
first attempt at this, and they were right to: making the pin win outright
would have opened My Geofences on the pin instead of the polygons you came
there to edit. The ladder is custom > location > selection > all, which is
correct on both pages. Tests updated to state the new intent, plus one for
the case the reorder makes newly reachable.

My pin
The section was labelled Location while the rest of the site says My pin.

Alert language
Moved from a block on the Areas page into the user menu beneath display
language, relabelled as a pair. It was on that page for want of anywhere
better, and its description had to end by explaining it was not the other
language setting — prose compensating for layout. Adjacent, the distinction
is structural. Also removed the user menu's Areas & Location item label,
which the nav rename had left stale in all eleven locales; it now shares the
nav's own string so a rename cannot orphan it again.
The Delivery tab and the Where sheet asked the same question in two shapes,
and the difference was not only cosmetic: the tab had two options where the
sheet had three. "Only in specific areas" could not be chosen while creating
an alarm, only afterwards from the card chip. Its copy, "Notify within a
radius from your location", also predated saved places and was false.

ScopePickerComponent is now the control. The sheet is a dialog shell around
it; all ten add dialogs render it inline. The sheet's vocabulary won because
it is the only one that can express the three states PoracleNG stores, and
WHERE.SHEET_TITLE -- "Where should this alert reach you?" -- is the inline
heading, so it is literally the same question in both places.

It also carries the missing-pin warning: choosing to measure from a pin you
have never set produced an alarm that silently matched nothing.

Alert language renders flag rows like display language rather than an
embedded select. Its state moved into a service so the menu can render rows
directly and keep Material's keyboard handling; two menus that sit next to
each other only read as different things if they look like siblings.

The edit dialogs still hold the old two-option control. Converting them the
same way ran into a DOTALL regex eating past its intended block, so I reverted
rather than push a half-converted file. They keep working as they were and get
the same treatment in a follow-up, done one at a time.
Edit dialogs
All ten now render ScopePickerComponent, the same control the add dialogs and
the card sheet use. They had kept the old two-option version, which could not
express an area-confined alarm — that is why they showed the scope read-only
and hid the control for those alarms. With a control that can describe all
three states, neither workaround is needed.

Done one file at a time after the scripted attempt went wrong last round. The
script this time verifies every piece is where it expects before writing
anything, and skips the file otherwise: five of eight skipped on the first
pass because they compute distance differently, and were converted separately
rather than guessed at. One still needed a hand-fix afterwards.

WHERE.EDIT_FROM_CARD is gone from all eleven locales. It told people to
change the scope from the card, which is no longer the only place.

Map
The Areas & Places map container set no height and let the child decide,
which left it too small to pick an area out of. It now matches My Geofences:
70vh, 500px floor, same margins and framing.

Three max-battle edit tests drove the removed form controls; they drive the
picker's signal now and their assertions are untouched.
)

Four add dialogs asked for COMMON.SAVED and COMMON.ERROR, neither of which
exists, so they showed the literal key. The keys they should have used were
already present and already translated: INVASIONS/QUESTS/RAIDS
SNACK_CREATED_COUNT and MAX_BATTLES.CREATE_SUCCESS/CREATE_FAILED. That was a
wrong reference, not a missing translation.

The audit that found it also found 121 keys sitting in English in all ten
locales. Translated the 38 that are prose a user reads: every sign-in error,
the test-alert messages, fort-change and max-battle snackbars, the raid level
picker's help and validation, the distance validation, and Undo.

Deliberately left in English: proper nouns (Arlo, Sierra, Team Rocket, the
team names), game terminology the community uses untranslated (Gigantamax,
GMAX, raid tier names, the DTS condition names), unit formats like "{{value}}
km", and the webhook URL placeholder. Admin-only settings strings are also
still English and are the obvious next batch.

The applier asserts each translation keeps every {{placeholder}} the English
has, since a dropped one renders literal braces and stays invisible until
someone in that language hits that string.

Also, from the walkthrough:
- The place picker was seeded with 0,0, so it opened in the Atlantic. It now
  starts from the pin, and is titled for picking a point rather than setting
  a location, which is what the other caller does.
- The missing-pin warning offers to set the pin, in place, rather than only
  naming the problem. Sending someone to Areas & Places would lose the alarm.
- Both language menus carry a line saying what they change.
- Removed a literal NUL byte that an earlier escape had written into the
  scope picker's source, which was making git treat the file as binary.
… input (#743)

* fix(i18n): real messages instead of raw keys, and 380 translations

Four add dialogs asked for COMMON.SAVED and COMMON.ERROR, neither of which
exists, so they showed the literal key. The keys they should have used were
already present and already translated: INVASIONS/QUESTS/RAIDS
SNACK_CREATED_COUNT and MAX_BATTLES.CREATE_SUCCESS/CREATE_FAILED. That was a
wrong reference, not a missing translation.

The audit that found it also found 121 keys sitting in English in all ten
locales. Translated the 38 that are prose a user reads: every sign-in error,
the test-alert messages, fort-change and max-battle snackbars, the raid level
picker's help and validation, the distance validation, and Undo.

Deliberately left in English: proper nouns (Arlo, Sierra, Team Rocket, the
team names), game terminology the community uses untranslated (Gigantamax,
GMAX, raid tier names, the DTS condition names), unit formats like "{{value}}
km", and the webhook URL placeholder. Admin-only settings strings are also
still English and are the obvious next batch.

The applier asserts each translation keeps every {{placeholder}} the English
has, since a dropped one renders literal braces and stays invisible until
someone in that language hits that string.

Also, from the walkthrough:
- The place picker was seeded with 0,0, so it opened in the Atlantic. It now
  starts from the pin, and is titled for picking a point rather than setting
  a location, which is what the other caller does.
- The missing-pin warning offers to set the pin, in place, rather than only
  naming the problem. Sending someone to Areas & Places would lose the alarm.
- Both language menus carry a line saying what they change.
- Removed a literal NUL byte that an earlier escape had written into the
  scope picker's source, which was making git treat the file as binary.

* fix(where): quick picks, one dialog size, and a picker that reads its input

You asked why the two Set Location dialogs looked different. They are the
same component — six callers each passed their own width and one said 400px.
The component owns its width now and no caller passes one.

Quick picks kept their own copy of the old two-option control, and the apply
request had no field for a place or a set of areas, so converting the UI
alone would have made the control lie. QuickPickApplyRequest carries both now
and QuickPickService puts them on every alarm it creates, all nine types.

The quick-pick test then caught something worse. ScopePickerComponent seeded
itself in the constructor, where a signal input is not yet populated, so it
read its own model default and wrote that back over whatever the host passed.
Every host's scope was being discarded: the Alert Defaults preference on a
new alarm, and an alarm's actual scope when you opened it to edit. Seeding
moved to ngOnInit and there is now a spec that fails on the old behaviour.

Also from the walkthrough: the place picker opens on your pin rather than
0,0, its title says it is picking a point, the missing-pin warning offers to
set the pin in place, and both language menus say what they change.

* docs: tidy the changelog list after the merge
Making the dialog one size, I put the width on :host. That sizes the
component inside Material's padded surface, so the content box came out wider
than its container and the whole dialog got a horizontal scrollbar.

The size belongs on mat-dialog-content, with border-box and a max-width, which
is what the other dialogs in the app already do. Same single size, no overflow.
I gave the mega control a fieldset with class="pvp-evolution" and never wrote
any CSS for it. Browsers style a bare fieldset with a border and inset
padding, and it reserved no space beneath itself, so it drew a box around
itself and the Best/Worst Rank fields ran into it.

The PVP cap control immediately above it already solves this with
.pvp-cap-fieldset and .pvp-cap-legend. Reusing those is the fix; there was
never a reason for a second set of classes.

Applied to both the add and edit dialogs, which carry the same markup.
Driven Playwright against dev rather than reasoning from CSS, which is how
the last several of these got shipped broken.

PVP tab
The rank fields carry a mat-hint that fills the row to its bottom edge, and
the next field's label floats above its own border into that band. Measured:
the row ended at exactly the y the next field began. .form-row now leaves
12px, in both pokemon dialogs.

Scope picker on a phone
At 390px the warning's icon was crushed to a sliver and "Set your pin" was
squeezed into three lines against the edge. It wraps now, and the 2.25rem
indent that lines options up with the radio labels drops to 1.25rem on small
screens, where it was costing a fifth of the usable width.

Set Location dialog
The surface stopped scrolling with the earlier fix, but 2px remained. The
Leaflet container is width:100% with a 1px border and no border-box, so it
came out 514px against its siblings' 512. That was the whole scrollbar.

Places empty state
Title said "No places yet" and the line under it opened with "No places yet."

Checked at 1400px and 390px: Areas & Places, the alarm dialog's four tabs,
the scope picker in all three modes, and the Set Location dialog.
Walking every page at 390px, two overflowed. The geofence header's three buttons
measure 404px in a 390px viewport, putting Draw Geofence 30px past the edge; they
now wrap, with the FAB on its own line. Help's small screenshots set a bare
max-width: 480px, which overrides the base class's max-width: 100%, so three
images rendered at 482px and spilled 92px; min(480px, 100%) keeps the cap without
letting it exceed the column.

Both measured in the browser rather than inferred. The remaining wide elements at
that width are the admin tables, which sit in a container that genuinely scrolls,
and Material's off-screen inactive tab panel on Raids.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
…ft (#748)

pvp_ranking_evolution shipped as a PVP-tab selector, an Angular request field,
a card suffix and four passing component specs, against a backend with no such
property. Model binding dropped it on the way in and the typed read dropped it
on the way out, so the control changed nothing and the suffix could never
render. The specs passed because they assert what the component puts in the
request, which says nothing about what the API accepts.

min_time is the other 5.1.0 filter PoracleWeb never carried: seconds a spawn
must still have left when it is found. Exposed as a short list of round
durations rather than a seconds box — 301 of production's rules use this field
and every one is set to exactly 300. A bot-set value outside the presets is
offered as its own option so opening the dialog cannot round or clear it.

rarity/max_rarity stay unmodelled on purpose. PoracleNG reads rarity per
species from rolling sighting stats, and PoracleWeb only writes species-
specific rules, so the filter is a constant on anything it can create: no-op or
permanent mute. Zero of 17,420 production rules set it. PokemonFieldCoverageTests
records that reasoning and fails the build if PoracleNG grows a column that is
neither written nor excused.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Stardust is the only reward type PoracleNG matches that PoracleWeb could not
create. The list already rendered such rules -- it has had a STARDUST_AMOUNT
string all along -- so the gap showed up as a reward you could see and not ask
for. It gets its own tab, and its floor travels in `reward` rather than
`amount`, because singleRewardMatches reads `reward` as the dust threshold for
reward type 3 and ignores `amount` there.

`amount` is the minimum quantity for the three rewards that come in
quantities: items, candy and mega energy. PoracleNG has always stored it and
PoracleWeb never sent it, so every rule created here asked for one of
whatever it was. The card title now carries it, since a rule that fires only
on three-plus and says nothing about it reads as broken.

The edit dialog is unchanged on purpose: it does not edit the reward either.
Both are identity fields in PoracleNG's diff, so changing one is a different
rule rather than an edit of this one.

Not added: the quest shiny flag. PoracleNG only matches it when the scanner
reports shiny-eligibility on the reward, which cannot be confirmed for this
deployment, and the failure mode of guessing wrong is a rule that silently
matches nothing. Zero production rules set it.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Both found by walking the new controls at 390px and 1400px on dev.

The "Minimum Time Left" hint wraps to two lines at phone width, and Material
reserves one line of subscript space, so it overlapped the SIZE heading below
it by 4px. Headings in More Filters now carry a top margin; several locales
wrap that hint at desktop width too, so this is not a phone-only fix.

Adding the Stardust tab pushed the reward tab group from 418px to 520px in a
464px rail, so Material paginated it and put the newest tab behind an arrow on
every screen. Trimming the tab padding fits all five with room to spare; phone
width still scrolls, as it did with four.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Found by creating a Mega X rule on dev and opening it again: the PVP tab
showed League, Best/Worst Rank and Min CP, and no mega control at all.

The picker was nested inside the level-cap fieldset, whose @if
(showCapPicker()) is false on any server that does not advertise PVP level
caps -- which is this deployment. So the add dialog offered it, the edit dialog
did not, and a mega rule was create-only. It is now a sibling of the cap
picker, matching the add dialog, and carries the same hint.

No data was at risk: the form control was seeded from the alarm and sent back
unchanged, verified against the dev row -- min_time 300 and
pvp_ranking_evolution 2 both survived an edit, on the same uid.

The regression test compares the two templates rather than the DOM. Material
mounts only the active tab body, and driving a tab switch in this zoneless
harness costs more than the assertion is worth; the defect was a control in the
wrong block, so the block structure is what is asserted. It fails on the old
markup.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Swept every add dialog against its edit twin after the mega picker turned out
to be create-only, comparing the form controls each template actually renders.
Nine alarm types, three differences, one of them a defect: the quest minimum
amount I added hours ago existed only in the add dialog. A card reading
"3x Rare Candy" had no way back to the 3.

Both editable fields here are thresholds -- they narrow the rule without
changing which reward it is about. The reward itself stays fixed, because
turning Rare Candy into a Poke Ball describes a different alarm rather than an
edit of this one. Stardust is the exception that proves it: PoracleNG reads
`reward` as the dust floor for that type, so there it is a threshold and is
editable.

The other two differences are real and now written down: pokemon `forms` is a
fan-out multi-select with no single-alarm equivalent, and max battles names the
same flag `gmaxOnly` when creating and `gmax` when editing.

alarm-dialog-parity.spec.ts holds the sweep. Each exception carries its reason,
and a third test fails if an exception no longer describes a real difference,
so the list cannot rot into permission to drift. Verified red by deleting the
control it was written for.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Nine strings sat in the templates as English literals, so the ten non-English
locales rendered them untranslated no matter what the user picked: the two Max
Battle tab hints, both Gigantamax toggle hints, the all-levels note, the
(Gigantamax) option suffix, and Fort Type / Change Types / Fort Change
Tracking. Found by scanning every component template for text nodes with no
translate pipe.

Terminology follows what each locale already uses for "Max Battle" and "fort"
rather than a fresh translation of the term -- de "Max-Kampf", it "Battaglia
Max", sv "Max-strid", and so on -- so the new hints read as part of the page
they sit on.

Two English sentences were also passive next to their toggle ("When enabled,
only notifies about..."). They say the same thing in the active voice now,
which is what got translated.

The PVP level cap's aria-label was hardcoded English as well. It now binds to
POKEMON.PVP_CAP, the same string its visible legend uses, so screen readers get
the reader's language without a duplicate key.

Still English: the four strings on the admin landing page. The whole admin
section is untranslated by an existing decision, and doing one page of it would
read as an oversight rather than a choice.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Cross-checked PoracleWeb against PoracleNG main (c5e08cb4, 5.1.0 — the commit
production runs) in three directions: every column each of the ten
*TrackingAPI structs stores, every filter prefix the Discord commands accept,
and the 48 command names themselves.

One defect. PoracleNG's fort webhook reports `description` among its edit
types, the `!fort` command accepts it and the matcher compares it
case-insensitively -- PoracleWeb drew five checkboxes and not that one. Since
the stored list is rebuilt from the checkboxes on every save, a rule a user set
with the bot lost `description` the next time they touched the radius on the
web. Now it has a box, and anything else PoracleNG grows is carried through
rather than rebuilt away. Production has 13 fort rules and none of them use
`description`, so nothing was lost in the field.

FortChangeOptions.ValidChangeTypes was also missing it. Nothing reads that set
today, which is the only reason this was latent rather than a refusal: an
allowlist that does not match what upstream accepts is the shape that has bitten
this codebase repeatedly.

TrackingFieldCoverageTests now runs over all ten tracking types instead of two.
Each excuse is keyed by type.column, `*.column` covers the ones common to every
type, and a stale excuse fails the build. The only columns unaccounted for
anywhere are pokemon rarity/max_rarity, whose reason is now accurate: it is a
per-species tier, so it is constant on the rules PoracleWeb creates, meaningful
only on the 534 track-everything rules the bot owns, and set by nobody.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Comment thread Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Services/TrackingFieldPreserver.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Services/UserOwnedOverrideAreaProxy.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Services/PoracleHumanProxy.cs Fixed
hokiepokedad2 and others added 2 commits August 19, 2026 16:01
* docs: cut changelog for v2.15.2

* docs: cut changelog for v2.15.3

---------

Co-authored-by: poracleweb-net-release[bot] <291134500+poracleweb-net-release[bot]@users.noreply.github.com>
@hokiepokedad2

Copy link
Copy Markdown
Contributor Author

Holding this — not cutting a release yet, on the owner's call. The branch is ready when that changes: CI is green and #757 clears the CHANGELOG conflict. Nothing here has shipped; docker-publish.yml builds :latest on release: published, not on a push to main.

chore: merge main into develop so the release can fast-forward
PoracleWeb assumed 5.1.0 and never checked. Against an older server the
features that need it write columns that do not exist: PoracleNG's decoder
drops the unknown field, the write returns 200, and the filter does nothing.
That is indistinguishable from a bug in PoracleWeb, which is the failure mode
this whole release has been about.

Two reads, because they answer different questions. /health -- unauthenticated,
so no secret and it still answers when the API key is wrong -- gives the release
number and PoracleNG's own capability map. The applied migration number from
schema_migrations gives what the map does not: which alarm columns exist. 5.1.0
sits at 5; costume lands at 6 and 7 on their develop branch.

Not branch detection, deliberately. PoracleNG stamps its branch into the binary
but publishes only the version, so a develop build between releases reports the
last release's number and cannot be told apart. It is also the wrong question:
self-hosters run forks and cherry-picks, and what matters is whether this server
can store a given field.

Every unknown resolves to unsupported -- unreachable, unparseable version,
unreadable schema. The opposite default would offer controls that write nothing.
"0.0.0" is treated as unknown rather than ancient, because that is what an
un-stamped local build reports and shouting "upgrade" at it would teach admins
to ignore the banner.

Nothing is gated yet; there is nothing to gate. Every feature in this build
works on 5.1.0. This is the mechanism, plus the floor check that has immediate
value, and the four hardcoded English strings on the admin landing page are
translated on the way past.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Caught by opening /admin on dev after deploying #758: the card was not there.
AdminComponent is not routed. /admin redirects to /admin/users, and the sidebar
links to users, webhooks and settings directly, so the landing page has been
dead code -- which is also why its four hardcoded English strings survived
every translation pass. Nobody could get to them.

The card moves to the top of Admin -> Settings, extracted as its own component
rather than more markup in a file that is already long, and testable on its own,
which the version on an unrouted page was not. The dead page and the five
translation keys that existed only for it are deleted.

Shipping an invisible control is the same defect this card was built to expose,
so: found in a browser, not in a test. The unit tests passed on the dead page
too.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Two projects, two ways of publishing, so two ways of reading the latest.
PoracleWeb cuts GitHub releases, so its latest is a tag name. PoracleNG has no
releases and no tags -- checked, the API returns an empty list -- and keeps its
version as a constant in processor/version.go, so the released number is read
from that file on main.

That file also settles the branch question I got wrong earlier. I said a
develop build reports the last release's number and so cannot be told apart.
It does not: version.go is bumped at the start of a cycle, so main reads 5.1.0
while develop already reads 5.2.0. A binary reporting more than main is a
development build by definition, and the card names it as one instead of
claiming it is out of date.

That does not change the gating design. What may I offer is still a capability
question -- a fork with costume backported would report an unrelated version --
and this is a maintenance question. They want different signals and now have
them.

This is the only outbound request PoracleWeb makes. It sends nothing, is cached
for six hours against a 60-per-hour anonymous allowance, and disable_update_check
switches it off before any call is made rather than discarding the result.
Everything unknown -- unreachable, unparseable, a beta channel, a local build --
renders as no line at all, because a false "you are behind" costs more than a
missing one.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Comment thread Tests/Pgan.PoracleWebNet.Tests/Services/UpdateCheckServiceTests.cs Fixed
Comment thread Tests/Pgan.PoracleWebNet.Tests/Services/UpdateCheckServiceTests.cs Fixed
The card described the Poracle server and only mentioned PoracleWeb when it had
an update to report. So on a current deployment it said nothing about the site,
and on dev -- where the channel is "beta" and not comparable to a release --
it said nothing either. The half an admin is most likely to be behind on was
the half that never appeared.

Both now get a section: version, build reference, and a line that says up to
date, behind, a development build, or -- for a rolling channel that cannot be
compared -- which release is current. The card is titled Versions rather than
Poracle server, because it is about both.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Spotted while taking a documentation screenshot of the Delivery tab: "Message
Settings" was flush against the scope picker, measured 0px of gap.

The heading margin added in #750 exempts the first heading in a tab, which is
right when that heading starts the tab and wrong otherwise. :first-of-type
matches the first h4 among its siblings whatever precedes it, so a heading that
follows a control still lost its margin. :first-child is the predicate that
matches the intent.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
* fix(ui): first-child, not first-of-type, for dialog headings

Spotted while taking a documentation screenshot of the Delivery tab: "Message
Settings" was flush against the scope picker, measured 0px of gap.

The heading margin added in #750 exempts the first heading in a tab, which is
right when that heading starts the tab and wrong otherwise. :first-of-type
matches the first h4 among its siblings whatever precedes it, so a heading that
follows a control still lost its margin. :first-child is the predicate that
matches the intent.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ

* docs: catch the documentation up with what the site does

Audited the README, the 31-page docs site, the in-app help and both screenshot
sets against the code. 101 findings: 35 sentences that would actively mislead a
reader today, 38 features never documented, 21 stale, 7 cosmetic.

The corrections that matter most are the ones that had gone wrong rather than
merely missing. The help told people to open "Areas & Location", which is now
Areas & Places. Alarm docs described the two-mode "Use Areas or Set Distance"
delivery model that the per-alarm scope replaced. The fort change_types list
omitted description. Quests had no filter documentation at all, so stardust and
minimum amounts had nowhere to be described. internationalization.md sent
readers to a page that had been renamed and a language setting that had moved.

The in-app guide is corrected in all eleven locales. That content is 37,000 to
42,000 characters of HTML per locale, and the audit found fr.json is condensed
rather than faithful, so edits matched sentences by content rather than
position.

Eleven screenshots were retaken at 1440x900 against the running dev deployment,
with demo data seeded directly into that database and removed afterwards -- the
account is back to the state it was in. Four are new: the scope picker, the
delivery chip on a card, the Places section, and the Versions card.

Every factual claim the writing pass made was checked against the code by a
separate reviewer. Nineteen failed and were corrected, including one this
session had repeated in a PR description: the update check is not the only
outbound call PoracleWeb makes -- MasterDataService fetches the masterfile from
raw.githubusercontent.com on any visit.

Also fixed while reading: docs disagreed with each other about where weather
data comes from and which writes bypass the proxy.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
Comment thread site/assets/javascripts/lunr/tinyseg.js Fixed
I ran `mkdocs build --strict` to check the docs site still builds, which writes
site/ next to docs/, and the commit in #763 swept 125 generated files and 12MB
into the repository. Nothing reads them: docs.yml runs mkdocs gh-deploy itself
from docs/.

Removed and added to .gitignore so the next person checking a docs build does
not repeat it.

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
The documentation audit reported that disable_user_geofences does not gate
DELETE custom/{id}, which is true: the attribute is on rename, create, submit,
activate, deactivate and import, and the service gates the same five. Delete is
covered by neither.

It should stay that way. Disabling the feature hides the page and refuses new
work, but fences that already exist keep being served in the feed and keep
matching, so a gated delete leaves someone receiving alerts from an area they
can neither edit nor remove.

The alarm types gate their whole controller and get away with it because the
bot is the escape hatch -- !untrack removes an alarm whatever the web says.
Geofences are PoracleWeb-only and none of the bot's 48 commands manages a drawn
polygon, so this endpoint is a user's only route to their own data. Production
holds 42 of them.

No behaviour change. The reason is now in the code, and a test pins the split:
delete ungated, the other six gated, and the class itself must not acquire the
attribute -- which would silently re-gate the delete and the reads with it.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
They were captured against a running deployment, so the signed-in account's
Discord handle and avatar came along: the handle in the user menu, the avatar in
the toolbar of every full-page shot.

The user menu is retaken under a placeholder name with a neutral silhouette. The
other ten are patched in place rather than retaken, because the demo data they
show was torn down afterwards and re-seeding it to change 28 pixels is not a
trade worth making. The silhouette matches what the app already draws for a user
with no Discord picture, so it reads as a default avatar rather than a redaction.

Refs #730

Claude-Session: https://claude.ai/code/session_01Nah4N2sGFs1TU2t7DXfKzJ
@hokiepokedad2
hokiepokedad2 marked this pull request as ready for review August 20, 2026 02:20
@hokiepokedad2
hokiepokedad2 added this pull request to the merge queue Aug 20, 2026
Any commits made after this event will not be merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant